assistant/scripts/eval_run.py

165 lines
7.0 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.

"""同一任务连续跑 N 次,统计成功率 / 步数 / 时长,结果写入 data/eval/*.jsonl。
用法(后端需已启动;端口取 .env 的 API_PORT,可用环境变量 API_PORT 覆盖):
.venv\\Scripts\\python.exe scripts\\eval_run.py --n 3 --message "帮我填今天的查课日志,班级计科2301班,听课记录还好" --supplement "课程是高等数学,授课教师是张老师"
默认是干跑(dry run):Agent 走到「提交前确认」时脚本自动回复取消,不会真正提交,
用来测「到确认为止」的步数和时长。加 --submit 才会在确认时回复「确认提交」。
其他中途提问(如问听课地点)自动回复 --auto-answer。
"""
import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from dotenv import load_dotenv # noqa: E402
load_dotenv(ROOT / ".env")
PORT = int(os.getenv("API_PORT", "8788"))
BASE = f"http://127.0.0.1:{PORT}/api"
TERMINAL = {"completed", "submitted", "failed"}
CANCEL_ANSWER = "取消,不要提交。这次是评测干跑,请保存 cancelled 后 done 说明表单已填未提交。"
CONFIRM_ANSWER = "确认提交"
def api(method: str, path: str, body: dict | None = None) -> dict:
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read().decode("utf-8"))
def count_steps(task_id: str) -> int | None:
try:
doc = api("GET", f"/tasks/{task_id}/progress-doc")["content"]
except Exception: # noqa: BLE001
return None
return sum(1 for line in doc.splitlines() if line.startswith("### ") and "步骤 " in line)
def is_confirm_question(q: str) -> bool:
return ("确认" in q and "提交" in q) or "即将提交" in q
def run_once(args, message: str) -> dict:
t0 = time.time()
task = api("POST", "/tasks/start", {"knowledge_id": "xuegong_demo", "message": message, "student_id": args.student})
tid = task["id"]
print(f" task {tid} | {task.get('business_name') or ''} | {task['status']} | {task['reply'][:80]}")
if task["status"] == "collecting":
if not args.supplement:
return {"task_id": tid, "status": "collecting", "needs": task.get("needs_input"), "duration_s": 0}
task = api("POST", f"/tasks/{tid}/inputs", {"message": args.supplement})
print(f" supplement -> {task['status']} | {task['reply'][:80]}")
pauses = 0
confirm_at = None
steps_at_confirm = None
status = task["status"]
while time.time() - t0 < args.timeout:
time.sleep(3)
t = api("GET", f"/tasks/{tid}")
status = t["status"]
if status == "paused":
pauses += 1
q = t.get("pause_reason") or ""
print(f" [paused #{pauses} @ {round(time.time() - t0)}s] {q[:160].replace(chr(10), ' ')}")
if pauses > 3:
break
if is_confirm_question(q) and confirm_at is None:
confirm_at = round(time.time() - t0, 1)
steps_at_confirm = count_steps(tid)
api("POST", f"/tasks/{tid}/human", {"answer": CONFIRM_ANSWER if args.submit else CANCEL_ANSWER})
else:
api("POST", f"/tasks/{tid}/human", {"answer": args.auto_answer})
continue
if status in TERMINAL:
break
else:
status = f"timeout({status})"
t = api("GET", f"/tasks/{tid}")
artifacts = t.get("artifacts") or {}
drafts = []
try:
drafts = api("GET", f"/learned?source_task_id={tid}&status=draft")
except Exception: # noqa: BLE001
pass
return {
"ts": datetime.now().isoformat(timespec="seconds"),
"task_id": tid,
"status": status,
"dry_run": not args.submit,
"duration_s": round(time.time() - t0, 1),
"steps": count_steps(tid),
"confirm_at_s": confirm_at,
"steps_to_confirm": steps_at_confirm,
"pauses": pauses,
"artifact_keys": sorted(k for k in artifacts if k != "summary"),
"learned_drafts": [d["key"] for d in drafts],
"summary": (artifacts.get("summary") or t.get("pause_reason") or "")[:300],
}
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--n", type=int, default=3)
ap.add_argument("--message", default="帮我填今天的查课日志,班级计科2301班,听课记录还好")
ap.add_argument("--supplement", default="", help="任务进入 collecting 时自动补充的一句话")
ap.add_argument("--student", default="default")
ap.add_argument("--timeout", type=float, default=900)
ap.add_argument("--auto-answer", default="不需要,留空继续")
ap.add_argument("--submit", action="store_true", help="到确认时真正确认提交(默认干跑:取消)")
args = ap.parse_args()
try:
meta = api("GET", "/meta")
except urllib.error.URLError as e:
print(f"后端未启动({BASE}):{e}")
return 1
print(f"后端 pid={meta['pid']} version={meta['version']} db={meta['db']} school={meta.get('school_id')} | {'真实提交' if args.submit else '干跑(到确认即取消)'}")
out_dir = ROOT / "data" / "eval"
out_dir.mkdir(parents=True, exist_ok=True)
out_path = out_dir / f"eval_{datetime.now().strftime('%Y%m%d_%H%M%S')}.jsonl"
results: list[dict] = []
for i in range(1, args.n + 1):
print(f"[{i}/{args.n}] 开始:{args.message}")
r = run_once(args, args.message)
results.append(r)
with out_path.open("a", encoding="utf-8") as f:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
print(
f" -> {r['status']} | {r['duration_s']}s | steps={r.get('steps')} | "
f"到确认 {r.get('steps_to_confirm')} 步 / {r.get('confirm_at_s')}s | drafts={r.get('learned_drafts')}"
)
ok = [r for r in results if r["status"] in ("completed", "submitted")]
durs = [r["duration_s"] for r in results if r["duration_s"]]
steps = [r["steps"] for r in results if r.get("steps") is not None]
to_confirm = [r["steps_to_confirm"] for r in results if r.get("steps_to_confirm") is not None]
confirm_s = [r["confirm_at_s"] for r in results if r.get("confirm_at_s") is not None]
print()
print(f"成功 {len(ok)}/{len(results)}")
if durs:
print(f"平均总时长 {sum(durs) / len(durs):.1f}s(最短 {min(durs)}s,最长 {max(durs)}s)")
if steps:
print(f"平均总步数 {sum(steps) / len(steps):.1f}")
if to_confirm:
print(f"到确认平均 {sum(to_confirm) / len(to_confirm):.1f} 步 / {sum(confirm_s) / len(confirm_s):.0f}s")
print(f"明细:{out_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())