项目文件夹

文件
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

505 行
20 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""
Clock app state accessor.
"""
from __future__ import annotations
import datetime
import re
from typing import Any
from bench_env.task.base import BaseApp
from bench_env.task.utils import norm, now_ms, format_hhmm
from bench_env.task.common_tasks import match_time
CLOCK_SELECTED_CITIES = ["伦敦", "纽约", "阿克拉", "巴黎"]
CLOCK_NEW_CITIES = ["阿比让", "阿德莱德", "阿尔伯克基", "阿尔及尔", "阿克套", "阿拉木图", "阿姆斯特丹", "北京", "东京", "洛杉矶", "悉尼"]
CLOCK_COMPARE_NEW_CITIES = ["阿比让", "阿尔伯克基", "阿尔及尔", "阿克套", "阿拉木图", "阿姆斯特丹", "北京", "东京", "洛杉矶", "悉尼"]
CLOCK_LATEST_NEW_CITIES = ["北京", "东京", "悉尼"]
CLOCK_REPEAT_VALUES = {
"只响一次": "once",
"每天": "daily",
"法定工作日": "workday",
"法定节假日": "holiday",
"周一至周五": "weekday",
}
CLOCK_NOTE_VALUES = ["晨练", "看球", "学习", "接人", "出差提醒"]
ALARM_TIME_PARAM = ["04:30", "05:00", "06:00", "06:10", "06:20", "07:00", "22:30"]
NEW_ALARM_CANDIDATES = [
{"hour": 7, "minute": 10},
{"hour": 7, "minute": 20},
{"hour": 7, "minute": 30},
{"hour": 7, "minute": 40},
{"hour": 7, "minute": 50},
{"hour": 8, "minute": 10},
{"hour": 8, "minute": 20},
{"hour": 8, "minute": 30},
{"hour": 8, "minute": 40},
{"hour": 8, "minute": 50},
{"hour": 9, "minute": 10},
{"hour": 9, "minute": 20},
]
CLOCK_ALARM_CHANGES = ["clock.alarms"]
class Clock(BaseApp):
"""
Clock state accessor.
Usage:
clock = Clock(input.apps["clock"])
clock.alarms
"""
@staticmethod
def _city_lookup(cities: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
return {str(city["id"]): city for city in cities}
@staticmethod
def _find_city(cities: list[dict[str, Any]], name_or_id: str) -> dict[str, Any]:
target = norm(name_or_id)
for city in cities:
if target in {norm(str(city["id"])), norm(str(city["name"]))}:
return dict(city)
raise ValueError(f"Clock city '{name_or_id}' not found")
@staticmethod
def _local_offset_minutes(os_state: dict[str, Any]) -> int:
ts = now_ms(os_state)
local_dt = datetime.datetime.fromtimestamp(ts / 1000.0, datetime.timezone.utc).astimezone()
offset = local_dt.utcoffset()
if offset is None:
raise ValueError("Clock local timezone offset is unavailable")
return int(round(offset.total_seconds() / 60))
@staticmethod
def sample_existing_alarm(env_state: dict[str, Any], rng: Any) -> dict[str, Any]:
alarms = env_state["apps"]["clock"]["alarms"]
picked = rng.choice(alarms)
return {
"alarm_id": str(picked["id"]),
"time": format_hhmm(int(picked["hour"]), int(picked["minute"])),
"hour": int(picked["hour"]),
"minute": int(picked["minute"]),
}
@staticmethod
def sample_noted_alarm(env_state: dict[str, Any], rng: Any) -> dict[str, Any]:
alarms = [alarm for alarm in env_state["apps"]["clock"]["alarms"] if str(alarm.get("note") or "").strip()]
picked = rng.choice(alarms)
return {
"alarm_id": str(picked["id"]),
"time": format_hhmm(int(picked["hour"]), int(picked["minute"])),
"hour": int(picked["hour"]),
"minute": int(picked["minute"]),
}
@staticmethod
def sample_new_alarm_time(env_state: dict[str, Any], rng: Any) -> dict[str, Any]:
existing = {
format_hhmm(int(alarm["hour"]), int(alarm["minute"]))
for alarm in env_state["apps"]["clock"]["alarms"]
}
available = [item for item in NEW_ALARM_CANDIDATES if format_hhmm(item["hour"], item["minute"]) not in existing]
picked = rng.choice(available)
return {
"time": format_hhmm(picked["hour"], picked["minute"]),
"hour": picked["hour"],
"minute": picked["minute"],
}
@staticmethod
def sample_existing_alarm_and_new_time(env_state: dict[str, Any], rng: Any) -> dict[str, Any]:
picked_alarm = Clock.sample_existing_alarm(env_state, rng)
picked_time = Clock.sample_new_alarm_time(env_state, rng)
return {
"alarm_id": picked_alarm["alarm_id"],
"old_time": picked_alarm["time"],
"old_hour": picked_alarm["hour"],
"old_minute": picked_alarm["minute"],
"new_time": picked_time["time"],
"new_hour": picked_time["hour"],
"new_minute": picked_time["minute"],
}
@staticmethod
def sample_two_new_alarm_times(env_state: dict[str, Any], rng: Any) -> dict[str, Any]:
existing = {
format_hhmm(int(alarm["hour"]), int(alarm["minute"]))
for alarm in env_state["apps"]["clock"]["alarms"]
}
available = [item for item in NEW_ALARM_CANDIDATES if format_hhmm(item["hour"], item["minute"]) not in existing]
first, second = rng.sample(available, 2)
if (first["hour"], first["minute"]) > (second["hour"], second["minute"]):
first, second = second, first
return {
"time1": format_hhmm(first["hour"], first["minute"]),
"h1": first["hour"],
"m1": first["minute"],
"time2": format_hhmm(second["hour"], second["minute"]),
"h2": second["hour"],
"m2": second["minute"],
}
@staticmethod
def sample_selected_city(env_state: dict[str, Any], rng: Any) -> dict[str, Any]:
city = rng.choice(Clock._selected_cities_from_env(env_state))
return {"city": str(city["name"])}
@staticmethod
def sample_selected_city_pair(env_state: dict[str, Any], rng: Any) -> dict[str, Any]:
selected = Clock._selected_cities_from_env(env_state)
pairs = [
(city1, city2)
for city1 in selected
for city2 in selected
if city1["id"] != city2["id"]
and int(city1["gmtOffsetMinutes"]) != int(city2["gmtOffsetMinutes"])
and abs(int(city1["gmtOffsetMinutes"]) - int(city2["gmtOffsetMinutes"])) % 60 == 0
]
city1, city2 = rng.choice(pairs)
return {"city1": str(city1["name"]), "city2": str(city2["name"])}
@staticmethod
def sample_addable_city(env_state: dict[str, Any], rng: Any) -> dict[str, Any]:
selected_ids = set(env_state["apps"]["clock"]["selectedCityIds"])
all_cities = env_state["apps"]["clock"]["cities"]
candidates = [city for city in all_cities if str(city["id"]) not in selected_ids]
picked = rng.choice(candidates)
return {"city": str(picked["name"])}
@staticmethod
def sample_compare_addable_city(env_state: dict[str, Any], rng: Any) -> dict[str, Any]:
selected_ids = set(env_state["apps"]["clock"]["selectedCityIds"])
all_cities = env_state["apps"]["clock"]["cities"]
candidates = [
city for city in all_cities
if str(city["id"]) not in selected_ids and str(city["name"]) in CLOCK_COMPARE_NEW_CITIES
]
picked = rng.choice(candidates)
return {"city": str(picked["name"])}
@staticmethod
def sample_latest_addable_city(env_state: dict[str, Any], rng: Any) -> dict[str, Any]:
selected_ids = set(env_state["apps"]["clock"]["selectedCityIds"])
all_cities = env_state["apps"]["clock"]["cities"]
candidates = [
city for city in all_cities
if str(city["id"]) not in selected_ids and str(city["name"]) in CLOCK_LATEST_NEW_CITIES
]
picked = rng.choice(candidates)
return {"city": str(picked["name"])}
@staticmethod
def sample_remove_add_city(env_state: dict[str, Any], rng: Any) -> dict[str, Any]:
remove_city = rng.choice(Clock._selected_cities_from_env(env_state))
add_city = Clock.sample_addable_city(env_state, rng)
return {"remove_city": str(remove_city["name"]), "add_city": add_city["city"]}
@staticmethod
def sample_compare_city_pair_with_new(env_state: dict[str, Any], rng: Any) -> dict[str, Any]:
new_city = Clock.sample_compare_addable_city(env_state, rng)
selected = Clock._selected_cities_from_env(env_state)
all_cities = env_state["apps"]["clock"]["cities"]
new_offset = int(Clock._find_city(all_cities, new_city["city"])["gmtOffsetMinutes"])
existing_choices = [
city for city in selected
if abs(new_offset - int(city["gmtOffsetMinutes"])) % 60 == 0
and new_offset != int(city["gmtOffsetMinutes"])
]
existing_city = rng.choice(existing_choices)
return {"new_city": new_city["city"], "existing_city": str(existing_city["name"])}
@staticmethod
def sample_city_not_local_offset(env_state: dict[str, Any], rng: Any) -> dict[str, Any]:
local_offset = Clock._local_offset_minutes(env_state["os"])
choices = [
city for city in Clock._selected_cities_from_env(env_state)
if int(city["gmtOffsetMinutes"]) != local_offset
]
picked = rng.choice(choices)
return {"city": str(picked["name"])}
@staticmethod
def _selected_cities_from_env(env_state: dict[str, Any]) -> list[dict[str, Any]]:
clock_state = env_state["apps"]["clock"]
selected_ids = clock_state.get("selectedCityIds", [])
all_cities = clock_state.get("cities", [])
lookup = Clock._city_lookup(all_cities)
return [dict(lookup[str(city_id)]) for city_id in selected_ids if str(city_id) in lookup]
@property
def all_cities(self) -> list[dict[str, Any]]:
cities = self.get("cities")
if not isinstance(cities, list):
raise ValueError("clock.cities is not a list")
return cities
@property
def alarms(self) -> list[dict[str, Any]]:
alarms = self.get("alarms")
if not isinstance(alarms, list):
raise ValueError("clock.alarms is not a list")
return alarms
@property
def selected_city_ids(self) -> list[str]:
selected_ids = self.get("selectedCityIds")
if not isinstance(selected_ids, list):
raise ValueError("clock.selectedCityIds is not a list")
return [str(city_id) for city_id in selected_ids]
@property
def selected_cities(self) -> list[dict[str, Any]]:
state_selected = self.get("selectedCities")
if isinstance(state_selected, list) and state_selected:
return state_selected
lookup = Clock._city_lookup(self.all_cities)
result = []
for city_id in self.selected_city_ids:
if city_id not in lookup:
raise ValueError(f"Clock selected city id '{city_id}' not found in catalog")
result.append(dict(lookup[city_id]))
return result
def find_alarm_by_id(self, alarm_id: str) -> dict[str, Any] | None:
for alarm in self.alarms:
if str(alarm.get("id")) == str(alarm_id):
return alarm
return None
def find_alarm_by_time(self, hour: int, minute: int) -> dict[str, Any] | None:
for alarm in self.alarms:
alarm_hour = alarm.get("hour")
alarm_minute = alarm.get("minute")
if alarm_hour is None or alarm_minute is None:
continue
if int(alarm_hour) == int(hour) and int(alarm_minute) == int(minute):
return alarm
return None
def find_alarm_at(
self, hour: int, minute: int, *, note: str | None = None
) -> dict[str, Any] | None:
for alarm in self.alarms:
alarm_hour = alarm.get("hour")
alarm_minute = alarm.get("minute")
if alarm_hour is None or alarm_minute is None:
continue
if int(alarm_hour) != int(hour):
continue
if int(alarm_minute) != int(minute):
continue
if note is not None and norm(str(alarm.get("note") or "")) != norm(note):
continue
return alarm
return None
def check_alarm_at(
self,
hour: int,
minute: int,
*,
note: str | None = None,
field: str = "alarm",
) -> dict[str, Any]:
alarm = self.find_alarm_at(hour, minute, note=note)
expected: dict[str, Any] = {"time": format_hhmm(hour, minute)}
if note is not None:
expected["note"] = note
actual = None
if alarm is not None:
actual = {
"time": format_hhmm(int(alarm["hour"]), int(alarm["minute"])),
"note": str(alarm.get("note") or ""),
}
return {
"field": field,
"expected": expected,
"actual": actual,
"passed": alarm is not None,
}
# ---- 对比层(需要 init----
def new_alarms(self) -> list[dict[str, Any]]:
"""init 后新增的闹钟(按 ID 差集)"""
init_ids = {str(a["id"]) for a in self.init.alarms}
return [a for a in self.alarms if str(a["id"]) not in init_ids]
def removed_alarm_ids(self) -> set[str]:
"""init 后被删除的闹钟 ID 集合"""
curr_ids = {str(a["id"]) for a in self.alarms}
return {str(a["id"]) for a in self.init.alarms} - curr_ids
# ---- 检查层(返回 check dict,内含 assert----
def check_created_alarm(
self, hour: int, minute: int, *,
field: str = "alarm_created", **attrs: Any,
) -> dict[str, Any]:
"""增:在新增闹钟中查找匹配时间+属性的项。"""
assert self.init.find_alarm_by_time(hour, minute) is None, \
f"Sampler bug: alarm {format_hhmm(hour, minute)} already in init"
new = self.new_alarms()
match = next(
(a for a in new
if int(a["hour"]) == hour and int(a["minute"]) == minute
and all(str(a.get(k)) == str(v) for k, v in attrs.items())),
None,
)
expected: dict[str, Any] = {"time": format_hhmm(hour, minute)}
expected.update(attrs)
return {"field": field, "expected": expected,
"actual": match, "passed": match is not None}
def check_deleted_alarm(
self, alarm_id: str, *, field: str = "alarm_deleted",
) -> dict[str, Any]:
"""删:确认目标 ID 在已删除集合中。"""
assert self.init.find_alarm_by_id(alarm_id) is not None, \
f"Sampler bug: alarm '{alarm_id}' not in init"
removed = self.removed_alarm_ids()
return {"field": field, "expected": alarm_id,
"actual": removed, "passed": str(alarm_id) in removed}
def check_alarm_fields(
self, alarm_id: str, *, field: str | None = None, **expected: Any,
) -> dict[str, Any]:
"""改:用 current 验证修改结果。"""
assert self.init.find_alarm_by_id(alarm_id) is not None, \
f"Sampler bug: alarm '{alarm_id}' not in init"
alarm = self.find_alarm_by_id(alarm_id)
fld = field or f"alarm_{alarm_id}"
if alarm is None:
return {"field": fld, "expected": expected,
"actual": None, "passed": False}
passed = all(str(alarm.get(k)) == str(v) for k, v in expected.items())
return {"field": fld, "expected": expected,
"actual": {k: alarm.get(k) for k in expected}, "passed": passed}
def check_no_new_alarm_at(
self,
hour: int,
minute: int,
*,
note: str | None = None,
field: str = "alarm_not_created",
) -> dict[str, Any]:
init_alarm = self.init.find_alarm_at(hour, minute, note=note)
curr_alarm = self.find_alarm_at(hour, minute, note=note)
return {
"field": field,
"expected": {
"time": format_hhmm(hour, minute),
"note": note,
"created": False,
},
"actual": {"init": init_alarm, "curr": curr_alarm},
"passed": curr_alarm == init_alarm,
}
def check_no_new_alarms(
self, *, field: str = "no_new_alarms"
) -> dict[str, Any]:
"""确认本次任务没有新增任何闹钟。"""
new = self.new_alarms()
preview = [
{
"id": alarm.get("id"),
"time": format_hhmm(int(alarm["hour"]), int(alarm["minute"])),
"note": str(alarm.get("note") or ""),
}
for alarm in new[:5]
]
return {
"field": field,
"expected": "不新增任何闹钟",
"actual": {"addedCount": len(new), "addedPreview": preview},
"passed": len(new) == 0,
}
def find_city(self, name_or_id: str) -> dict[str, Any]:
return Clock._find_city(self.all_cities, name_or_id)
def selected_city_matches(self, name_or_id: str) -> bool:
target = norm(name_or_id)
return any(
target in {norm(str(city["id"])), norm(str(city["name"]))}
for city in self.selected_cities
)
def city_time(self, name_or_id: str, os_state: dict[str, Any]) -> str:
city = self.find_city(name_or_id)
utc_dt = datetime.datetime.fromtimestamp(now_ms(os_state) / 1000.0, datetime.timezone.utc)
city_dt = utc_dt + datetime.timedelta(minutes=int(city["gmtOffsetMinutes"]))
return f"{city_dt.hour:02d}:{city_dt.minute:02d}"
def check_city_time_answer(
self, name_or_id: str, os_state: dict[str, Any],
answer: Any, *, field: str = "answer",
) -> dict[str, Any]:
"""验证 Agent 回答是否包含城市当前时间(±5 分钟容忍)。"""
expected = self.city_time(name_or_id, os_state)
return {
"field": field,
"expected": expected,
"actual": answer,
"passed": match_time(expected, answer),
}
def city_local_diff_text(self, name_or_id: str, os_state: dict[str, Any]) -> re.Pattern:
city = self.find_city(name_or_id)
diff_minutes = int(city["gmtOffsetMinutes"]) - Clock._local_offset_minutes(os_state)
if diff_minutes == 0:
return re.compile(r"一样|相同|没有(?:时)?差")
sign = "快" if diff_minutes > 0 else "慢"
abs_minutes = abs(diff_minutes)
hours = abs_minutes // 60
minutes = abs_minutes % 60
# 构建容忍助词(了、个、约 等)的正则
parts: list[str] = []
if hours > 0:
parts.append(rf"{hours}\s*(?:个)?小时")
if minutes > 0:
parts.append(rf"{minutes}\s*(?:个)?分(?:钟)?")
time_part = r"\s*".join(parts)
return re.compile(rf"{sign}\S{{0,3}}{time_part}")
def time_diff_hours(self, city1: str, city2: str) -> int:
first = self.find_city(city1)
second = self.find_city(city2)
diff_minutes = abs(int(first["gmtOffsetMinutes"]) - int(second["gmtOffsetMinutes"]))
if diff_minutes % 60 != 0:
raise ValueError(f"Clock city diff between '{city1}' and '{city2}' is not an integer hour")
return diff_minutes // 60
def latest_city_name(self, city_names: list[str] | None = None) -> Any:
"""时区最靠东:``gmtOffsetMinutes`` 最大(相对格林尼治向东偏移最大)。
若有多个城市同偏移并列,返回可同时匹配任一城市名的正则。
"""
if city_names is None:
cities = self.selected_cities
else:
cities = [self.find_city(name) for name in city_names]
if not cities:
raise ValueError("Clock selected cities are empty")
max_off = max(int(city["gmtOffsetMinutes"]) for city in cities)
winners = [str(city["name"]) for city in cities if int(city["gmtOffsetMinutes"]) == max_off]
if len(winners) == 1:
return winners[0]
return re.compile("|".join(re.escape(name) for name in winners))