Files
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

777 lines
28 KiB
Python
Raw Permalink 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.
"""Task listing collection and rendering helpers."""
from __future__ import annotations
from dataclasses import dataclass
import inspect
from pathlib import Path
from typing import Any
from bench_env.task import TaskRegistry
_MAX_DEFAULT_LEN = 24
@dataclass(frozen=True)
class ParamInfo:
name: str
type: str
default: str
in_template: bool
source: str
@dataclass(frozen=True)
class TaskSummary:
task_id: str
description: str
templates: list[str]
difficulty: str
scope: str
objective: str
composition: str
capabilities: list[str]
optimal_paths: int
max_steps: int | None
apps: list[str]
param_list: list[ParamInfo]
sampled_count: int | None = None
@dataclass(frozen=True)
class TaskGroup:
suite: str
tasks: list[TaskSummary]
# ── helpers ──────────────────────────────────────────────────────────────
def _extract_templates(task_cls: type) -> list[str]:
"""Collect instruction templates from a task class."""
raw = getattr(task_cls, "templates", [])
if isinstance(raw, str):
raw = [raw]
templates: list[str] = []
if isinstance(raw, (list, tuple)):
for t in raw:
text = str(t or "").strip()
if text and text not in templates:
templates.append(text)
return templates
def _evaluate_default_value(raw_default: Any) -> Any:
"""Evaluate zero-arg callable defaults for listing purposes."""
if not callable(raw_default):
return raw_default
try:
sig = inspect.signature(raw_default)
except (TypeError, ValueError):
return raw_default
required = [
p for p in sig.parameters.values()
if p.default is inspect._empty
and p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
]
if required:
return raw_default
try:
return raw_default()
except Exception:
return raw_default
def _resolve_display_default(
raw_default: Any,
pschema: dict,
env_state: dict[str, Any] | None = None,
) -> str:
"""Apply display mapping to a default value for listing purposes."""
raw_default = _evaluate_default_value(raw_default)
if raw_default is None:
return ""
display = pschema.get("display")
if display is None:
values = pschema.get("values")
if isinstance(values, dict):
display = {iv: dv for dv, iv in values.items()}
if display is not None:
if isinstance(display, dict) and raw_default in display:
return str(display[raw_default])
if isinstance(display, str):
from bench_env.task.base import _BUILTIN_DISPLAY
builtin = _BUILTIN_DISPLAY.get(display)
if builtin is not None:
return builtin(raw_default)
if callable(display):
try:
n = len(inspect.signature(display).parameters)
except (TypeError, ValueError):
n = 1
try:
if n >= 2 and env_state is not None:
return str(display(raw_default, env_state))
if n < 2:
return str(display(raw_default))
except Exception:
pass
if isinstance(raw_default, bool):
return "开启" if raw_default else "关闭"
return str(raw_default)
def _fallback_task_description(task: Any, templates: list[str]) -> str:
"""Fallback formatter for listing when env-aware display cannot run."""
if getattr(task, "task_name", ""):
return str(task.task_name)
if not templates:
return ""
tpl = templates[0]
render_params: dict[str, Any] = {}
for k, v in getattr(task, "params", {}).items():
schema = getattr(task, "parameters", {}).get(k, {})
display = schema.get("display")
if display is None:
values = schema.get("values")
if isinstance(values, dict):
display = {iv: dv for dv, iv in values.items()}
if display is not None and hasattr(task, "_apply_display"):
try:
render_params[k] = task._apply_display(k, v, display)
continue
except Exception:
pass
render_params[k] = "开启" if isinstance(v, bool) and v else "关闭" if isinstance(v, bool) else v
try:
return tpl.format(**render_params)
except Exception:
return tpl
def _render_task_description(task: Any, templates: list[str]) -> str:
"""Render description for listing without letting one formatter break all tasks."""
try:
rendered = task.description
except Exception:
rendered = _fallback_task_description(task, templates)
return rendered or "(no description)"
def _collect_params(
task_cls: type,
templates: list[str],
*,
env_state: dict[str, Any] | None = None,
) -> list[ParamInfo]:
"""Collect parameter metadata from a task class."""
parameters = getattr(task_cls, "parameters", {}) or {}
if not parameters:
return []
tpl_text = "\n".join(templates)
result: list[ParamInfo] = []
for pname, pschema in parameters.items():
ptype = str(pschema.get("type", "")).strip() or "?"
raw_default = pschema.get("default")
pdefault = _resolve_display_default(raw_default, pschema, env_state=env_state)
psource = str(pschema.get("source", "") or "").strip()
in_tpl = ("{" + pname + "}") in tpl_text
result.append(ParamInfo(
name=pname, type=ptype, default=pdefault,
in_template=in_tpl, source=psource,
))
return result
def _truncate(s: str, maxlen: int = _MAX_DEFAULT_LEN) -> str:
return s if len(s) <= maxlen else s[: maxlen - 1] + "…"
def _has_params(task: TaskSummary) -> bool:
return len(task.param_list) > 0
def _count_parameterized(groups: list[TaskGroup]) -> int:
return sum(1 for g in groups for t in g.tasks if _has_params(t))
def _count_tpl_used(groups: list[TaskGroup]) -> int:
"""Tasks where at least one param appears as {param} in template."""
return sum(
1 for g in groups for t in g.tasks
if any(p.in_template for p in t.param_list)
)
def _has_sampled(groups: list[TaskGroup]) -> bool:
return any(t.sampled_count is not None for g in groups for t in g.tasks)
def _total_sampled(groups: list[TaskGroup]) -> int:
return sum(t.sampled_count or 1 for g in groups for t in g.tasks)
def _group_sampled(tasks: list[TaskSummary]) -> int:
return sum(t.sampled_count or 1 for t in tasks)
# ── collect ──────────────────────────────────────────────────────────────
def _task_cls_matches_filters(
task_cls: type,
filter_difficulty: list[str] | None,
filter_objective: list[str] | None,
filter_composition: list[str] | None,
filter_scope: list[str] | None,
filter_capabilities: list[str] | None,
filter_mode: str = "and",
) -> bool:
"""Return True if task_cls passes active filters.
AND mode: all active filters must match.
OR mode: at least one active filter must match.
"""
checks: list[bool] = []
if filter_difficulty:
checks.append(getattr(task_cls, "difficulty", None) in filter_difficulty)
if filter_objective:
checks.append(getattr(task_cls, "objective", None) in filter_objective)
if filter_composition:
checks.append(getattr(task_cls, "composition", None) in filter_composition)
if filter_scope:
checks.append(getattr(task_cls, "scope", None) in filter_scope)
if filter_capabilities:
task_caps = set(getattr(task_cls, "capabilities", []) or [])
checks.append(bool(task_caps.intersection(filter_capabilities)))
if not checks:
return True
return any(checks) if filter_mode == "or" else all(checks)
def collect_task_groups(
suite: list[str] | None = None,
*,
include_generated: bool = False,
env_state: dict[str, Any] | None = None,
sample_n: int | None = None,
filter_difficulty: list[str] | None = None,
filter_objective: list[str] | None = None,
filter_composition: list[str] | None = None,
filter_scope: list[str] | None = None,
filter_capabilities: list[str] | None = None,
filter_mode: str = "and",
filter_has_answer_fields: bool | None = None,
split_task_ids: frozenset[str] | set[str] | None = None,
) -> tuple[list[TaskGroup], int]:
"""Collect task metadata for rendering.
Args:
suite: Suite name(s) to filter. If None, shows all suites.
include_generated: Include generated task suites (from
``bench_env/generated_task/``). Default False.
env_state: Optional simulator state for env-aware display rendering.
sample_n: If set, compute sampled instance count per task.
filter_difficulty/objective/composition/scope/capabilities: Field-level
filters (AND between fields, OR within each field; capabilities=ANY).
"""
from bench_env.task.registry import _max_instances
registry = TaskRegistry()
selected = suite or registry.list_suites(include_generated=include_generated)
groups: list[TaskGroup] = []
total = 0
for suite_name in selected:
task_names = registry.list_tasks(suite_name)
if not task_names:
continue
tasks: list[TaskSummary] = []
for name in task_names:
if split_task_ids is not None and f"{suite_name}.{name}" not in split_task_ids:
continue
task_cls = registry.get(suite_name, name)
if not _task_cls_matches_filters(
task_cls,
filter_difficulty, filter_objective,
filter_composition, filter_scope, filter_capabilities,
filter_mode,
):
continue
if filter_has_answer_fields is not None:
has_af = bool(getattr(task_cls, "answer_fields", None))
if has_af != filter_has_answer_fields:
continue
task = task_cls()
if env_state is not None:
task._env_state = env_state
templates = _extract_templates(task_cls)
sampled = _max_instances(task_cls, sample_n) if sample_n is not None else None
tasks.append(
TaskSummary(
task_id=f"{suite_name}.{name}",
description=_render_task_description(task, templates),
templates=templates,
difficulty=task_cls.difficulty,
scope=task_cls.scope,
objective=task_cls.objective,
composition=task_cls.composition,
capabilities=list(task_cls.capabilities),
optimal_paths=len(task_cls.optimal_paths),
max_steps=getattr(task_cls, "max_steps", None),
apps=list(task_cls.apps),
param_list=_collect_params(task_cls, templates, env_state=env_state),
sampled_count=sampled,
)
)
total += 1
if tasks:
groups.append(TaskGroup(suite=suite_name, tasks=tasks))
return groups, total
# ── text output ──────────────────────────────────────────────────────────
def _format_param_text(p: ParamInfo) -> str:
"""Format a single param for terminal: {name}(type ="val") or [name](type ="val")."""
wrap_l, wrap_r = ("{", "}") if p.in_template else ("[", "]")
default_part = f' ="{_truncate(p.default)}"' if p.default else ""
source_part = f" ←{p.source}" if p.source else ""
return f"{wrap_l}{p.name}{wrap_r}({p.type}{default_part}{source_part})"
def render_task_list_text(groups: list[TaskGroup], total: int) -> str:
"""Render task list as terminal-friendly plain text."""
param_total = _count_parameterized(groups)
tpl_used_total = _count_tpl_used(groups)
show_sampled = _has_sampled(groups)
lines: list[str] = []
for group in groups:
group_param = sum(1 for t in group.tasks if _has_params(t))
header = f"\n[{group.suite}] ({len(group.tasks)} tasks, {group_param} parameterized"
if show_sampled:
header += f", {_group_sampled(group.tasks)} instances"
header += ")"
lines.append(header)
lines.append("-" * 60)
for task in group.tasks:
task_label = f" {task.task_id}"
if show_sampled and task.sampled_count is not None and task.sampled_count > 1:
task_label += f" ×{task.sampled_count}"
lines.append(task_label)
lines.append(f" {task.description}")
if task.param_list:
lines.append(f" params: {' | '.join(_format_param_text(p) for p in task.param_list)}")
lines.append(
f" {task.difficulty} | {task.scope} | "
f"{task.objective} | {task.composition}"
)
if task.capabilities:
lines.append(f" caps: {', '.join(task.capabilities)}")
if task.optimal_paths:
lines.append(f" optimal_paths: {task.optimal_paths} path(s)")
if task.max_steps is not None:
lines.append(f" max_steps: {task.max_steps}")
lines.append("")
summary = (
f"\nTotal: {total} tasks"
f" · {param_total} parameterized"
f" ({tpl_used_total} in-template, {param_total - tpl_used_total} internal-only)"
)
if show_sampled:
sampled_total = _total_sampled(groups)
summary += f"\nSampled: {sampled_total} instances (sample_n applied)"
return "\n".join(lines) + summary
# ── markdown helpers ─────────────────────────────────────────────────────
_DIFFICULTY_META: dict[str, tuple[str, str, int]] = {
"L1": ("🟢", "Easy", 1),
"L2": ("🔵", "Medium", 2),
"L3": ("🟡", "Hard", 3),
"L4": ("🔴", "Expert", 4),
}
_SUITE_ICONS: dict[str, str] = {
"account": "🔐",
"alipay": "💰",
"bilibili": "📺",
"crossapp_commerce": "🛍️",
"crossapp_content": "📰",
"crossapp_life": "🌤️",
"crossapp_work": "💼",
"device": "📱",
"ebay": "🛒",
"map": "🗺️",
"payment": "💳",
"railway12306": "🚄",
"redbook": "📕",
"spotify": "🎵",
"tencent_meeting": "📹",
"weather": "🌤️",
"wechat": "💬",
"wechat_reading": "📖",
"x": "🐦",
}
def _html_escape(value: object) -> str:
text = str(value)
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def _difficulty_sort_key(level: str) -> tuple[int, str]:
return _DIFFICULTY_META.get(level, ("", "", 999))[2], level
def _difficulty_badge(level: str) -> str:
icon, label, _ = _DIFFICULTY_META.get(level, ("⚪", level, 999))
return f"{icon} **{level}** {label}"
def _bar_chart(counts: dict[str, int], total: int) -> list[str]:
if not total:
return []
bar_width = 20
lines: list[str] = []
for level in sorted(counts, key=_difficulty_sort_key):
icon, label, _ = _DIFFICULTY_META.get(level, ("⚪", level, 999))
count = counts[level]
filled = round(count / total * bar_width)
bar = "█" * filled + "░" * (bar_width - filled)
pct = count / total * 100
lines.append(f"| {icon} **{level}** {label} | `{bar}` | **{count}** | {pct:.0f}% |")
return lines
def _render_param_html(p: ParamInfo) -> str:
"""Render one param as HTML snippet.
- in-template params → bold name
- internal-only params → italic name
"""
name_escaped = _html_escape(p.name)
if p.in_template:
name_html = f"<b>{name_escaped}</b>"
else:
name_html = f"<i>{name_escaped}</i>"
type_html = f"<sub>{_html_escape(p.type)}</sub>"
default_html = ""
if p.default:
default_html = f" <code>{_html_escape(_truncate(p.default))}</code>"
source_html = ""
if p.source:
source_html = f' <sub title="sampled from">←{_html_escape(p.source)}</sub>'
return f"{name_html} {type_html}{default_html}{source_html}"
def _render_params_cell(param_list: list[ParamInfo]) -> str:
if not param_list:
return "—"
return "<br>".join(_render_param_html(p) for p in param_list)
# ── markdown output ──────────────────────────────────────────────────────
def render_task_list_markdown(groups: list[TaskGroup], total: int) -> str:
"""Render task list as categorized Markdown tables with visual styling."""
global_difficulty: dict[str, int] = {}
for g in groups:
for t in g.tasks:
global_difficulty[t.difficulty] = global_difficulty.get(t.difficulty, 0) + 1
param_total = _count_parameterized(groups)
tpl_used = _count_tpl_used(groups)
internal_only = param_total - tpl_used
show_sampled = _has_sampled(groups)
summary_line = (
f"> **总任务数 {total}** · **{len(groups)} 个应用套件** · "
f"**带参数 {param_total}**(模板参数 {tpl_used} · 仅内部 {internal_only}"
)
if show_sampled:
summary_line += f" · **采样后实例 {_total_sampled(groups)}**"
lines = [
"# 📋 bench_env 任务列表",
"",
summary_line,
"",
"---",
"",
"## 📊 总览",
"",
]
# difficulty distribution
lines.extend([
"### 难度分布",
"",
"| 难度 | 分布 | 数量 | 占比 |",
"| :--- | :--- | ---: | ---: |",
])
lines.extend(_bar_chart(global_difficulty, total))
lines.append("")
# per-suite stats
diff_keys = sorted(global_difficulty, key=_difficulty_sort_key)
sampled_col = " | 采样实例" if show_sampled else ""
sampled_align = " | ---:" if show_sampled else ""
lines.extend([
"### 应用任务统计",
"",
"| 应用 | 任务数 | 带参数" + sampled_col + " | " + " | ".join(
f"{_DIFFICULTY_META.get(d, ('⚪', d, 999))[0]} {d}" for d in diff_keys
) + " |",
"| :--- | ---: | ---:" + sampled_align + " | " + " | ".join("---:" for _ in diff_keys) + " |",
])
for group in groups:
icon = _SUITE_ICONS.get(group.suite, "📦")
diff_counts: dict[str, int] = {}
for t in group.tasks:
diff_counts[t.difficulty] = diff_counts.get(t.difficulty, 0) + 1
p_count = sum(1 for t in group.tasks if _has_params(t))
cells = [f"{icon} **{group.suite}**", str(len(group.tasks)), str(p_count)]
if show_sampled:
cells.append(str(_group_sampled(group.tasks)))
for d in diff_keys:
c = diff_counts.get(d, 0)
cells.append(str(c) if c else "·")
lines.append("| " + " | ".join(cells) + " |")
lines.append("")
# param legend
lines.extend([
"### 参数标记说明",
"",
"| 样式 | 含义 |",
"| :--- | :--- |",
"| **粗体** 参数名 | 出现在任务模板 `{param}` 占位符中,Agent 可见 |",
"| *斜体* 参数名 | 仅用于环境准备 / 判题 / 采样,不出现在指令中 |",
"| `默认值` | 列举时使用的默认值(运行时可能被采样覆盖) |",
"| ←source | 从环境状态采样的路径 |",
"",
])
lines.extend(["---", ""])
# per-suite task tables
for group in groups:
icon = _SUITE_ICONS.get(group.suite, "📦")
difficulty_counts: dict[str, int] = {}
tasks_by_difficulty: dict[str, list[TaskSummary]] = {}
for task in group.tasks:
difficulty_counts[task.difficulty] = difficulty_counts.get(task.difficulty, 0) + 1
tasks_by_difficulty.setdefault(task.difficulty, []).append(task)
suite_param_count = sum(1 for t in group.tasks if _has_params(t))
suite_header = (
f"> **{len(group.tasks)}** 个任务 · **带参数 {suite_param_count}** · "
+ " ".join(
f"{_DIFFICULTY_META.get(d, ('⚪', d, 999))[0]} {d}×{difficulty_counts[d]}"
for d in sorted(difficulty_counts, key=_difficulty_sort_key)
)
)
if show_sampled:
suite_header += f" · **采样实例 {_group_sampled(group.tasks)}**"
lines.extend([
f"## {icon} {group.suite}",
"",
suite_header,
"",
])
for difficulty in sorted(tasks_by_difficulty, key=_difficulty_sort_key):
difficulty_tasks = tasks_by_difficulty[difficulty]
badge = _difficulty_badge(difficulty)
lines.extend([
f"### {badge} ({len(difficulty_tasks)})",
"",
"<table>",
"<tr>"
'<th align="right">#</th>'
"<th>Task ID</th>"
"<th>Params</th>"
"<th>App</th>"
'<th align="center">Max Steps</th>'
'<th align="center">Scope</th>'
'<th align="center">Objective</th>'
'<th align="center">Composition</th>'
"</tr>",
])
for idx, task in enumerate(difficulty_tasks, 1):
apps_text = ", ".join(task.apps) if task.apps else "—"
desc = _html_escape(task.description)
if task.description == "(no description)":
desc = "<em>(无描述)</em>"
tid = _html_escape(task.task_id)
sampled_html = ""
if show_sampled and task.sampled_count is not None and task.sampled_count > 1:
sampled_html = f" <sup>×{task.sampled_count}</sup>"
caps_html = ""
if task.capabilities:
caps_html = "<br>" + " ".join(
f"<code>{_html_escape(c)}</code>" for c in task.capabilities
)
params_html = _render_params_cell(task.param_list)
max_steps_html = str(task.max_steps) if task.max_steps is not None else "—"
lines.append(
f"<tr>"
f'<td align="right">{idx}</td>'
f"<td><b><code>{tid}</code></b>{sampled_html}<br>"
f"<sub>{desc}</sub>{caps_html}</td>"
f"<td>{params_html}</td>"
f"<td>{_html_escape(apps_text)}</td>"
f'<td align="center">{max_steps_html}</td>'
f'<td align="center"><code>{_html_escape(task.scope)}</code></td>'
f'<td align="center"><code>{_html_escape(task.objective)}</code></td>'
f'<td align="center"><code>{_html_escape(task.composition)}</code></td>'
f"</tr>"
)
lines.extend(["</table>", ""])
lines.extend(["---", ""])
footer = (
f"*共 **{total}** 个任务,"
f"其中 **{param_total}** 个带参数"
f"(模板参数 {tpl_used},仅内部 {internal_only}"
)
if show_sampled:
footer += f",采样后共 **{_total_sampled(groups)}** 个实例"
footer += f" · 由 `bench_env/task_listing.py` 自动生成*"
lines.append(footer)
lines.append("")
return "\n".join(lines)
# ── entry point ──────────────────────────────────────────────────────────
def _collect_listing_app_ids(
suite: list[str] | None = None,
*,
include_generated: bool = False,
) -> list[str]:
"""Collect app ids referenced by the selected task suites."""
registry = TaskRegistry()
selected = suite or registry.list_suites(include_generated=include_generated)
app_ids: list[str] = []
seen: set[str] = set()
for suite_name in selected:
for name in registry.list_tasks(suite_name):
task_cls = registry.get(suite_name, name)
for app_id in getattr(task_cls, "apps", []) or []:
app_text = str(app_id or "").strip()
if not app_text or app_text in seen:
continue
seen.add(app_text)
app_ids.append(app_text)
return app_ids
async def _load_listing_env_state(
suite: list[str] | None = None,
*,
include_generated: bool = False,
online: bool = False,
env_url: str | None = None,
proxy: str | None = None,
) -> tuple[dict[str, Any] | None, str | None]:
"""Best-effort simulator state loading for env-aware task listing."""
if not online:
return None, None
if not env_url:
return None, None
from bench_env.env import MobileGymEnv
app_ids = _collect_listing_app_ids(suite, include_generated=include_generated)
env = MobileGymEnv(
url=env_url,
headless=True,
proxy=proxy,
coord_space="norm_0_1000",
delay_after_action=0.0,
verbose=False,
viewport_size=(360, 800),
physical_size=(1080, 2400),
device_scale_factor=3,
)
try:
await env.start()
await env.wait_ready(app_ids=app_ids or None)
state = await env.get_state()
if state:
return state, f"[INFO] 使用模拟器状态在线渲染任务文案: {env_url}"
return None, f"[WARN] 模拟器已连接,但未读取到有效 state,改为离线渲染: {env_url}"
except Exception as e:
return None, f"[WARN] 无法读取模拟器 state,改为离线渲染: {type(e).__name__}: {e}"
finally:
await env.close()
async def list_tasks(
suite: list[str] | None = None,
*,
include_generated: bool = False,
markdown_path: str | None = None,
online: bool = False,
env_url: str | None = None,
proxy: str | None = None,
sample_n: int | None = None,
filter_difficulty: list[str] | None = None,
filter_objective: list[str] | None = None,
filter_composition: list[str] | None = None,
filter_scope: list[str] | None = None,
filter_capabilities: list[str] | None = None,
filter_mode: str = "and",
filter_has_answer_fields: bool | None = None,
split_task_ids: frozenset[str] | set[str] | None = None,
) -> None:
"""List all available tasks and optionally export Markdown."""
env_state, notice = await _load_listing_env_state(
suite,
include_generated=include_generated,
online=online,
env_url=env_url,
proxy=proxy,
)
if notice:
print(notice)
groups, total = collect_task_groups(
suite,
include_generated=include_generated,
env_state=env_state,
sample_n=sample_n,
filter_difficulty=filter_difficulty,
filter_objective=filter_objective,
filter_composition=filter_composition,
filter_scope=filter_scope,
filter_capabilities=filter_capabilities,
filter_mode=filter_mode,
filter_has_answer_fields=filter_has_answer_fields,
split_task_ids=split_task_ids,
)
print(render_task_list_text(groups, total))
if markdown_path:
output_path = Path(markdown_path).expanduser()
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(render_task_list_markdown(groups, total), encoding="utf-8")
print(f"[INFO] Markdown written to {output_path}")