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

927 lines
33 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.
"""
eBay app state accessor.
"""
from __future__ import annotations
import json
import math
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
from bench_env.task.base import BaseApp
from bench_env.task.common_tasks import match_value, normalize_text
BuyingFormat = Literal["buyItNow", "auction", "offer"]
SortId = Literal["bestMatch", "priceLow", "priceHigh", "endingSoon", "newlyListed", "distance"]
EBAY_THEME_PARAM = {
"type": "enum",
"values": {
"浅色": "light",
"深色": "dark",
"节电模式": "battery",
},
"default": "dark",
"description": "eBay 主题",
}
EBAY_SEARCH_QUERY_PARAM = {
"type": "enum",
"values": [
"电风扇",
"耳机",
"运动鞋",
"吸尘器",
"电脑",
"电视",
"戒指",
"腕表",
"行李箱",
"发动机零件",
],
"default": "电风扇",
"description": "eBay 搜索关键词",
}
EBAY_SORT_PARAM = {
"type": "enum",
"values": {
"最低价 + 运费优先": "priceLow",
"最高价 + 运费优先": "priceHigh",
"新刊登优先": "newlyListed",
"距离:最近优先": "distance",
},
"default": "priceLow",
"description": "eBay 搜索排序方式",
}
EBAY_CATEGORY_VALUES = {
"电子产品": "electronics",
"服装、鞋子和配饰": "fashion",
"家庭和花园": "home-garden",
"珠宝和手表": "jewelry",
"eBay 汽车": "motors",
"机票及旅游": "travel",
}
EBAY_QUERY_CATEGORY_PAIRS = [
{"query": "电脑", "category": "electronics"},
{"query": "运动鞋", "category": "fashion"},
{"query": "吸尘器", "category": "home-garden"},
{"query": "戒指", "category": "jewelry"},
{"query": "发动机零件", "category": "motors"},
{"query": "行李箱", "category": "travel"},
]
EBAY_SEARCH_CHANGES = ["ebay.search", "ebay.recentSearches"]
@dataclass(frozen=True)
class Product:
id: str
title: str
categoryId: str
categoryLabel: str
typeId: str
typeLabel: str
brand: str
condition: str
price: float
originalPrice: float | None
shipping: float
freeShipping: bool
buyingFormat: BuyingFormat
dateListed: int
endingSoon: int
distanceKm: int
location: str
sales: str | None
isSponsored: bool | None
image: str
@property
def total_cost(self) -> float:
return float(self.price) + float(self.shipping)
ROOT = Path(__file__).resolve().parents[3]
PRODUCTS_PATH = ROOT / "apps" / "Ebay" / "data" / "products.json"
def load_products() -> list[Product]:
raw = json.loads(PRODUCTS_PATH.read_text(encoding="utf-8"))
products: list[Product] = []
for item in raw:
products.append(
Product(
id=str(item["id"]),
title=str(item["title"]),
categoryId=str(item["categoryId"]),
categoryLabel=str(item.get("categoryLabel") or ""),
typeId=str(item["typeId"]),
typeLabel=str(item.get("typeLabel") or ""),
brand=str(item["brand"]),
condition=str(item["condition"]),
price=float(item["price"]),
originalPrice=float(item["originalPrice"]) if item.get("originalPrice") is not None else None,
shipping=float(item["shipping"]),
freeShipping=bool(item["freeShipping"]),
buyingFormat=str(item["buyingFormat"]), # type: ignore[assignment]
dateListed=int(item["dateListed"]),
endingSoon=int(item["endingSoon"]),
distanceKm=int(item["distanceKm"]),
location=str(item["location"]),
sales=str(item["sales"]) if item.get("sales") else None,
isSponsored=bool(item["isSponsored"]) if item.get("isSponsored") is not None else None,
image=str(item["image"]),
)
)
return products
PRODUCTS: list[Product] = load_products()
# Mirrors apps/Ebay/pages/SearchPage.tsx COUNTRY_TO_CONTINENT
COUNTRY_TO_CONTINENT: dict[str, str] = {
"中国": "亚洲",
"日本": "亚洲",
"韩国": "亚洲",
"印度": "亚洲",
"美国": "北美洲",
"加拿大": "北美洲",
"墨西哥": "北美洲",
"英国": "欧洲",
"德国": "欧洲",
"法国": "欧洲",
"意大利": "欧洲",
"西班牙": "欧洲",
"澳大利亚": "大洋洲",
"新西兰": "大洋洲",
"巴西": "南美洲",
}
def _matches_location(product_location: str, selected_location: str) -> bool:
"""Match location the same way the frontend does (exact or continent)."""
if selected_location == product_location:
return True
return COUNTRY_TO_CONTINENT.get(product_location) == selected_location
def _normalize_search_text(text: str) -> str:
return re.sub(r"\s+", "", text.lower())
def filter_products(
*,
query: str | None = None,
category_id: str | None = None,
brand: str | None = None,
buying_format: BuyingFormat | None = None,
condition: str | None = None,
location: str | None = None,
free_shipping_only: bool = False,
min_total: float | None = None,
max_total: float | None = None,
) -> list[Product]:
query = _normalize_search_text((query or "").strip())
result: list[Product] = []
for product in PRODUCTS:
if category_id and product.categoryId != category_id:
continue
if brand and product.brand != brand:
continue
if buying_format and product.buyingFormat != buying_format:
continue
if condition and product.condition != condition:
continue
if location and not _matches_location(product.location, location):
continue
if free_shipping_only and not product.freeShipping:
continue
if min_total is not None and product.total_cost < min_total:
continue
if max_total is not None and product.total_cost > max_total:
continue
if query:
haystack = _normalize_search_text(
f"{product.title} {product.brand} {product.typeLabel} {product.categoryLabel}"
)
if query not in haystack:
continue
result.append(product)
return result
def sort_products(products: list[Product], sort_id: SortId) -> list[Product]:
if sort_id == "priceLow":
return sorted(products, key=lambda product: product.total_cost)
if sort_id == "priceHigh":
return sorted(products, key=lambda product: product.total_cost, reverse=True)
if sort_id == "newlyListed":
return sorted(products, key=lambda product: product.dateListed, reverse=True)
if sort_id == "endingSoon":
return sorted(products, key=lambda product: product.endingSoon)
if sort_id == "distance":
return sorted(products, key=lambda product: product.distanceKm)
return list(products)
def expect_top(
*,
query: str,
category_id: str | None = None,
sort_id: SortId,
brand: str | None = None,
buying_format: BuyingFormat | None = None,
condition: str | None = None,
location: str | None = None,
free_shipping_only: bool = False,
min_total: float | None = None,
max_total: float | None = None,
n: int = 1,
) -> list[Product]:
filtered = filter_products(
query=query,
category_id=category_id,
brand=brand,
buying_format=buying_format,
condition=condition,
location=location,
free_shipping_only=free_shipping_only,
min_total=min_total,
max_total=max_total,
)
sorted_list = sort_products(filtered, sort_id)
if len(sorted_list) < n:
raise ValueError(
f"Task design error: expected at least {n} results but got {len(sorted_list)} "
f"(query={query}, category={category_id or 'ANY'}, sort={sort_id})"
)
return sorted_list[:n]
def _snapshot_query_matches_intent(snap_query: str, canonical_query: str) -> bool:
"""Snapshot `query` is the full search box text; tasks use a canonical keyword (e.g. 耳机)."""
s = (snap_query or "").strip().lower()
c = (canonical_query or "").strip().lower()
if not c:
return True
if not s:
return False
if s == c:
return True
return c in s
def _snapshot_brand_matches(snapshot: dict[str, Any], expected_brand: str | None) -> bool:
"""Brand may be only in filters, only in combined query (e.g. Sony 耳机), or both."""
if expected_brand is None:
return True
eb = str(expected_brand).strip()
snap_brand_raw = str(snapshot.get("brand") or "").strip()
snap_query = str(snapshot.get("query") or "")
parts = [p.strip() for p in snap_brand_raw.split(",") if p.strip()]
if parts:
return eb in parts
return eb.lower() in snap_query.lower()
def _price_field_matches(actual: Any, expected: str | None) -> bool:
if expected is None:
return True
a = str(actual or "").strip()
b = str(expected).strip()
if a == b:
return True
try:
return float(a) == float(b)
except ValueError:
return False
def snapshot_matches_search_criteria(
snapshot: dict[str, Any] | None,
*,
query: str,
sort_option: str | None = None,
category_id: str | None = None,
brand: str | None = None,
buying_format: str | None = None,
condition: str | None = None,
location: str | None = None,
free_shipping_only: bool | None = None,
price_min: str | None = None,
price_max: str | None = None,
) -> bool:
"""Whether *snapshot* (a history entry or ``search.current``) matches the task filters."""
if not isinstance(snapshot, dict):
return False
if not _snapshot_query_matches_intent(str(snapshot.get("query") or ""), query):
return False
if sort_option is not None and str(snapshot.get("sortOption") or "") != sort_option:
return False
if category_id is not None and str(snapshot.get("categoryId") or "") != category_id:
return False
if brand is not None and not _snapshot_brand_matches(snapshot, brand):
return False
if buying_format is not None and str(snapshot.get("buyingFormat") or "") != buying_format:
return False
if condition is not None:
actual_conditions = sorted(str(item) for item in (snapshot.get("conditions") or []))
if actual_conditions != [condition]:
return False
if location is not None and str(snapshot.get("location") or "") != location:
return False
if free_shipping_only is not None and bool(snapshot.get("freeShippingOnly")) != free_shipping_only:
return False
if not _price_field_matches(snapshot.get("priceMin"), price_min):
return False
if not _price_field_matches(snapshot.get("priceMax"), price_max):
return False
return True
def expect_count(
*,
query: str,
category_id: str | None = None,
brand: str | None = None,
buying_format: BuyingFormat | None = None,
condition: str | None = None,
location: str | None = None,
free_shipping_only: bool = False,
min_total: float | None = None,
max_total: float | None = None,
) -> int:
return len(
filter_products(
query=query,
category_id=category_id,
brand=brand,
buying_format=buying_format,
condition=condition,
location=location,
free_shipping_only=free_shipping_only,
min_total=min_total,
max_total=max_total,
)
)
# =============================================================================
# Shared answer parsing helpers (for eBay tasks)
# =============================================================================
# Match the "count unit" in natural-language answers, e.g.:
# - "有 11 个结果"
# - "有 5 双"
_EBAY_COUNT_UNIT_RE = re.compile(r"(\d{1,5})\s*(?:个|条|双|件)(?:结果|条(?:记录)?)?")
def extract_two_counts_from_natural_answer(text: Any) -> tuple[int, int] | None:
"""
Extract the first two integer counts from an agent free-form answer.
Tight by design: only matches numbers followed by count units (个/条/双/件),
so it won't confuse price range bounds like 620/690 as counts.
"""
if text is None:
return None
s = normalize_text(str(text))
matches = [int(m.group(1)) for m in _EBAY_COUNT_UNIT_RE.finditer(s)]
if len(matches) >= 2:
return matches[0], matches[1]
return None
def infer_winner_label(first_count: int, second_count: int, first_label: str, second_label: str, tie_label: str = "相同") -> str:
if first_count > second_count:
return first_label
if second_count > first_count:
return second_label
return tie_label
# =============================================================================
# Answer matching helpers (moved from tasks.py per §1.1)
# =============================================================================
def _case_field_suffix(*parts: Any) -> str:
raw = "_".join(str(p) for p in parts if p is not None and str(p).strip())
return re.sub(r"[^\w\u4e00-\u9fff]+", "_", raw).strip("_") or "case"
_EBAY_STANDALONE_NUM_RE = re.compile(
r"(?<!\d)-?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?(?!\d)"
)
def _ebay_parse_floats_in_order(text: str) -> list[float]:
s = normalize_text(text)
out: list[float] = []
for m in _EBAY_STANDALONE_NUM_RE.finditer(s):
try:
out.append(float(m.group().replace(",", "").replace("", "")))
except ValueError:
continue
return out
def _ebay_match_price(expected: float, actual_fragment: Any) -> bool:
"""Match an expected price (yuan) against an actual value or text fragment."""
if actual_fragment is None:
return False
if isinstance(actual_fragment, bool):
return False
if isinstance(actual_fragment, (int, float)):
return math.isclose(float(actual_fragment), expected, rel_tol=1e-6, abs_tol=0.02)
for n in _ebay_parse_floats_in_order(str(actual_fragment)):
if math.isclose(n, expected, rel_tol=1e-6, abs_tol=0.02):
return True
return False
def _ebay_winner_label_matches_in_text(
label_expected: str,
full: str | None,
winner_marker_words: list[str] | None,
) -> bool:
"""Check winner label near the marker word."""
if full is None:
return False
full_norm = normalize_text(full)
low = label_expected.lower()
if label_expected == "相同" or low in ("same", "tied", "equal"):
return any(w in full_norm for w in ("相同", "一样", "same", "tied", "equal"))
if not winner_marker_words:
return match_value(label_expected, full_norm)
for marker in winner_marker_words:
marker_norm = normalize_text(marker)
if not marker_norm:
continue
if re.search(
rf"{re.escape(marker_norm)}[^。;,,]{{0,20}}{re.escape(label_expected)}",
full_norm,
):
return True
if re.search(
rf"{re.escape(label_expected)}[^。;,,]{{0,20}}{re.escape(marker_norm)}",
full_norm,
):
return True
return False
def build_compare_two_totals_checks(
*,
label_expected: str,
label_key: str,
first_total: float,
first_key: str,
second_total: float,
second_key: str,
winner_marker_words: list[str] | None = None,
answer: Any,
) -> list[dict[str, Any]]:
"""Two price slots + one label; plain string answers list prices in order."""
if isinstance(answer, dict):
return [
{"field": f"answer.{label_key}", "expected": label_expected,
"actual": answer.get(label_key),
"passed": match_value(label_expected, answer.get(label_key))},
{"field": f"answer.{first_key}", "expected": first_total,
"actual": answer.get(first_key),
"passed": _ebay_match_price(first_total, answer.get(first_key))},
{"field": f"answer.{second_key}", "expected": second_total,
"actual": answer.get(second_key),
"passed": _ebay_match_price(second_total, answer.get(second_key))},
]
full = None if answer is None else str(answer)
nums = _ebay_parse_floats_in_order(full) if full else []
first_actual = str(nums[0]) if len(nums) > 0 else None
second_actual = str(nums[1]) if len(nums) > 1 else None
return [
{"field": f"answer.{label_key}", "expected": label_expected,
"actual": full,
"passed": _ebay_winner_label_matches_in_text(label_expected, full, winner_marker_words)},
{"field": f"answer.{first_key}", "expected": first_total,
"actual": first_actual,
"passed": _ebay_match_price(first_total, first_actual)},
{"field": f"answer.{second_key}", "expected": second_total,
"actual": second_actual,
"passed": _ebay_match_price(second_total, second_actual)},
]
def build_compare_counts_checks(
*,
more_expected: str,
label1: str,
count1: int,
label2: str,
count2: int,
answer: Any,
) -> list[dict[str, Any]]:
"""Count-based comparison between two filtered groups."""
if isinstance(answer, dict):
return [
{"field": "answer.more", "expected": more_expected,
"actual": answer.get("more"),
"passed": match_value(more_expected, answer.get("more"))},
{"field": f"answer.{label1}Count", "expected": count1,
"actual": answer.get(f"{label1}Count"),
"passed": match_value(count1, answer.get(f"{label1}Count"))},
{"field": f"answer.{label2}Count", "expected": count2,
"actual": answer.get(f"{label2}Count"),
"passed": match_value(count2, answer.get(f"{label2}Count"))},
]
full = None if answer is None else str(answer)
# String-based: extract winner label and two counts
more_passed = False
if full:
pair = extract_two_counts_from_natural_answer(full)
if pair is not None:
inferred = infer_winner_label(pair[0], pair[1], first_label=label1, second_label=label2)
more_passed = inferred == more_expected
elif more_expected in full:
more_passed = True
return [
{"field": "answer.more", "expected": more_expected,
"actual": full, "passed": more_passed},
{"field": f"answer.{label1}Count", "expected": count1,
"actual": full, "passed": match_value(count1, full)},
{"field": f"answer.{label2}Count", "expected": count2,
"actual": full, "passed": match_value(count2, full)},
]
class Ebay(BaseApp):
"""
eBay state accessor.
Usage:
ebay = Ebay(input.apps["ebay"])
ebay.recent_searches
ebay.current_search
"""
@property
def recent_searches(self) -> list[dict[str, Any]]:
return self.get_list("recentSearches")
@property
def current_search(self) -> dict[str, Any]:
return self.get("search", {}).get("current", {})
@property
def search_history(self) -> list[dict[str, Any]]:
history = self.get("search", {}).get("history", [])
return history if isinstance(history, list) else []
@property
def last_compare(self) -> dict[str, Any] | None:
last_compare = self.get("search", {}).get("lastCompare")
return last_compare if isinstance(last_compare, dict) else None
@staticmethod
def sample_query_category_pair(env_state: dict[str, Any], rng: Any) -> dict[str, str]:
picked = rng.choice(EBAY_QUERY_CATEGORY_PAIRS)
return {"query": str(picked["query"]), "category": str(picked["category"])}
@staticmethod
def sample_two_items(env_state: dict[str, Any], rng: Any) -> dict[str, str]:
"""从搜索关键词候选列表中随机采样两个不同的商品关键词。"""
pool = list(EBAY_SEARCH_QUERY_PARAM["values"])
picked = rng.sample(pool, 2)
return {"item1": picked[0], "item2": picked[1]}
def find_latest_snapshot(
self,
*,
query: str,
sort_option: str | None = None,
category_id: str | None = None,
brand: str | None = None,
buying_format: str | None = None,
condition: str | None = None,
location: str | None = None,
free_shipping_only: bool | None = None,
price_min: str | None = None,
price_max: str | None = None,
) -> dict[str, Any] | None:
kw: dict[str, Any] = {
"query": query,
"sort_option": sort_option,
"category_id": category_id,
"brand": brand,
"buying_format": buying_format,
"condition": condition,
"location": location,
"free_shipping_only": free_shipping_only,
"price_min": price_min,
"price_max": price_max,
}
for snapshot in reversed(self.search_history):
if snapshot_matches_search_criteria(snapshot, **kw):
return snapshot
# Filter-only updates sync into ``search.current`` on every change; history is only
# appended on search/sort/apply. Accept the live current state when it matches.
cur = self.current_search
if snapshot_matches_search_criteria(cur, **kw):
return cur
return None
def cheapest_product(
self,
*,
query: str,
condition: str | None = None,
location: str | None = None,
brand: str | None = None,
) -> Product:
"""返回满足筛选条件的最低总价商品。"""
return expect_top(
query=query,
condition=condition,
location=location,
brand=brand,
sort_id="priceLow",
n=1,
)[0]
def check_search_snapshot(
self,
query: str,
*,
condition: str | None = None,
sort_option: str | None = None,
first_total_cents: int | None = None,
field: str | None = None,
) -> dict[str, Any]:
if field is None:
field = f"ebay.search.{query}"
snapshot = self.find_latest_snapshot(
query=query,
condition=condition,
sort_option=sort_option,
)
actual = None
passed = snapshot is not None
if snapshot is not None:
actual = {
"query": snapshot.get("query"),
"conditions": snapshot.get("conditions"),
"sortOption": snapshot.get("sortOption"),
"firstTotalCents": snapshot.get("firstTotalCents"),
}
if first_total_cents is not None:
try:
passed = passed and int(snapshot.get("firstTotalCents")) == int(first_total_cents)
except (TypeError, ValueError):
passed = False
expected: dict[str, Any] = {"query": query}
if condition is not None:
expected["condition"] = condition
if sort_option is not None:
expected["sortOption"] = sort_option
if first_total_cents is not None:
expected["firstTotalCents"] = int(first_total_cents)
return {
"field": field,
"expected": expected,
"actual": actual,
"passed": passed,
}
def check_current_search(
self,
query: str,
*,
sort_option: str | None = None,
first_total_cents: int | None = None,
field: str | None = None,
) -> dict[str, Any]:
"""仅校验 search.current(当前搜索页),不扫描历史记录。
用于需要验证"当前仍停留在目标搜索结果页"的任务判定。
"""
from bench_env.task.utils import norm as _norm
if field is None:
field = f"ebay.current.{query}"
cur = self.current_search
q = str(cur.get("query") or "")
sort_id = cur.get("sortOption")
cents = cur.get("firstTotalCents")
query_ok = bool(q) and _norm(query) in _norm(q)
sort_ok = sort_option is None or sort_id == sort_option
price_ok = True
if first_total_cents is not None:
try:
price_ok = int(cents) == int(first_total_cents)
except (TypeError, ValueError):
price_ok = False
passed = query_ok and sort_ok and price_ok
expected: dict[str, Any] = {"query": query}
if sort_option is not None:
expected["sortOption"] = sort_option
if first_total_cents is not None:
expected["firstTotalCents"] = int(first_total_cents)
return {
"field": field,
"expected": expected,
"actual": {"query": q, "sortOption": sort_id, "firstTotalCents": cents},
"passed": passed,
}
def compare_cheapest_products(
self,
*,
query1: str,
query2: str,
condition: str | None = None,
location: str | None = None,
) -> tuple[str, Product, Product, float]:
"""比较两次搜索的最低总价商品,返回更便宜的一方与差价。"""
first = self.cheapest_product(query=query1, condition=condition, location=location)
second = self.cheapest_product(query=query2, condition=condition, location=location)
first_total = round(first.total_cost, 2)
second_total = round(second.total_cost, 2)
if first_total < second_total:
return query1, first, second, round(second_total - first_total, 2)
if second_total < first_total:
return query2, first, second, round(first_total - second_total, 2)
return "相同", first, second, 0.0
# -- check methods --
def check_has_snapshot(
self,
*,
query: str,
brand: str | None = None,
condition: str | None = None,
location: str | None = None,
sort_option: str | None = None,
price_min: str | None = None,
price_max: str | None = None,
field: str | None = None,
) -> dict[str, Any]:
"""Check that a matching search snapshot exists."""
if field is None:
field = f"snapshot.{_case_field_suffix(brand, query, location)}"
snapshot = self.find_latest_snapshot(
query=query, brand=brand, condition=condition,
location=location, sort_option=sort_option,
price_min=price_min, price_max=price_max,
)
parts = [p for p in [brand, query, f"@ {location}" if location else None] if p]
if price_min or price_max:
parts.append(f"[{price_min or ''}, {price_max or ''}]")
return {
"field": field,
"expected": f"matching snapshot for {' '.join(parts)}",
"actual": snapshot,
"passed": snapshot is not None,
}
# -- answer methods --
def cheapest_product_answer(
self, *, query: str, brand: str | None = None,
condition: str | None = None, location: str | None = None,
) -> dict[str, Any]:
"""Answer method: {title, price} of cheapest matching product."""
p = self.cheapest_product(
query=query, condition=condition, location=location, brand=brand,
)
return {"title": p.title, "price": round(p.total_cost, 2)}
@staticmethod
def compare_top_totals(
q1: str, q2: str, *,
condition: str | None = None,
location: str | None = None,
sort_id: SortId = "priceLow",
) -> tuple[float, float]:
"""Return (first_total, second_total) in yuan for two queries."""
first = expect_top(
query=q1, condition=condition, location=location,
sort_id=sort_id, n=1,
)[0]
second = expect_top(
query=q2, condition=condition, location=location,
sort_id=sort_id, n=1,
)[0]
return (
round(first.total_cost, 2),
round(second.total_cost, 2),
)
# -- sampler staticmethods (moved from tasks.py per §1.1) --
@staticmethod
def sample_brand_location_case(env_state: dict[str, Any], rng: Any) -> dict[str, Any]:
"""Shared sampler for brand+location filtered searches (count / cheapest tasks)."""
candidates = [
{"query": "耳机", "brand": "Sony", "location": "欧洲", "condition": "全新"},
{"query": "运动鞋", "brand": "Nike", "location": "欧洲", "condition": "全新"},
{"query": "吸尘器", "brand": "Dyson", "location": "亚洲", "condition": "全新"},
{"query": "发动机零件", "brand": "Bosch", "location": "欧洲", "condition": "全新"},
]
valid = [
c for c in candidates
if expect_count(query=c["query"], brand=c["brand"],
condition=c["condition"], location=c["location"]) > 0
]
return rng.choice(valid or candidates)
@staticmethod
def sample_compare_pair(env_state: dict[str, Any], rng: Any) -> dict[str, Any]:
"""Sampler for L4 two-product price comparison tasks."""
pool = list(EBAY_SEARCH_QUERY_PARAM["values"])
pair = rng.sample(pool, 2)
modes = [
{"sort_id": "priceLow", "sort_label": "最低价", "extreme": "最便宜", "comparison": "更便宜"},
{"sort_id": "priceHigh", "sort_label": "最高价", "extreme": "最贵", "comparison": "更贵"},
]
mode = rng.choice(modes)
return {"item1": pair[0], "item2": pair[1], **mode}
@staticmethod
def sample_range_case(env_state: dict[str, Any], rng: Any) -> dict[str, Any]:
base_candidates = [
{"query": "运动鞋", "brand": "Nike", "location": "欧洲", "condition": "全新"},
{"query": "耳机", "brand": "Sony", "location": "欧洲", "condition": "全新"},
{"query": "吸尘器", "brand": "Dyson", "location": "亚洲", "condition": "全新"},
]
valid_cases: list[dict[str, Any]] = []
for c in base_candidates:
products = filter_products(
query=c["query"], brand=c["brand"],
condition=c["condition"], location=c["location"],
)
if not products:
continue
totals = sorted(int(round(p.total_cost)) for p in products)
pick = totals[min(len(totals) - 1, max(0, len(totals) // 3))]
for span in (20, 30, 40, 60):
lo = max(0, pick - span)
hi = pick + span
cnt = expect_count(
query=c["query"], brand=c["brand"],
condition=c["condition"], location=c["location"],
min_total=lo, max_total=hi,
)
if cnt > 0:
valid_cases.append({**c, "price_min": str(lo), "price_max": str(hi)})
break
if valid_cases:
return rng.choice(valid_cases)
return {
"query": "运动鞋", "brand": "Nike", "location": "欧洲",
"condition": "全新", "price_min": "510", "price_max": "540",
}
@staticmethod
def sample_compare_counts_groups(env_state: dict[str, Any], rng: Any) -> dict[str, Any]:
"""Sampler for L4 two-group count comparison tasks."""
candidates = [
{"query": "耳机", "brand": "Sony", "location": "欧洲", "condition": "全新"},
{"query": "运动鞋", "brand": "Nike", "location": "欧洲", "condition": "全新"},
{"query": "吸尘器", "brand": "Dyson", "location": "亚洲", "condition": "全新"},
{"query": "发动机零件", "brand": "Bosch", "location": "欧洲", "condition": "全新"},
]
def _with_range(c: dict[str, Any]) -> dict[str, Any] | None:
products = filter_products(
query=c["query"], brand=c["brand"],
condition=c["condition"], location=c["location"],
)
if not products:
return None
totals = sorted(int(round(p.total_cost)) for p in products)
pick = totals[min(len(totals) - 1, max(0, len(totals) // 3))]
for span in (20, 30, 40, 60):
lo, hi = max(0, pick - span), pick + span
if expect_count(query=c["query"], brand=c["brand"],
condition=c["condition"], location=c["location"],
min_total=lo, max_total=hi) > 0:
return {**c, "price_min": str(lo), "price_max": str(hi)}
return None
valid = [r for c in candidates if (r := _with_range(c)) is not None]
if len(valid) >= 2:
g1, g2 = rng.sample(valid, 2)
else:
g1 = {"query": "耳机", "brand": "Sony", "location": "欧洲",
"condition": "全新", "price_min": "620", "price_max": "690"}
g2 = {"query": "运动鞋", "brand": "Nike", "location": "欧洲",
"condition": "全新", "price_min": "510", "price_max": "540"}
return {f"{k}1": v for k, v in g1.items()} | {f"{k}2": v for k, v in g2.items()}