项目文件夹

文件
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 行
33 KiB
Python

"""
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()}