From 439b8cc7a49a76f5624e09ab37bdfbea2914ebb8 Mon Sep 17 00:00:00 2001 From: 1171-jpg <1409558858@qq.com> Date: Mon, 20 Jul 2026 18:37:03 -0700 Subject: [PATCH 1/2] feat(target): add Target mirror with real target.com content + task verifiers Adds the Target mirror as the 17th site (index 16, port 40016), rebuilt from real target.com data scraped with Playwright. Catalog: 1,932 products across 18 categories, 562 brands, with real product photography, 19,821 specification rows and 2,350 scraped guest reviews. Tasks: 20 WebVoyager tasks in sites/target/tasks.jsonl, each anchored on a fact that only appears on a detail page. Grading contract: sites/target/verify/ with verify_lib.py and one deterministic verifier per task, recorded as verifier_path + judge_rubric on every task row. Ground truth lives only in the verifiers, never in tasks.jsonl. Assets uploaded to HF as target.tar.gz (discussions/46). Based on the Target site skeleton from #51 by @Lxr-max, which was reassigned for rebuild after review. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PLRyFZ4TjY9pC7uZkKJC6H --- Dockerfile | 2 +- control_server.py | 2 +- sites/target/_health.py | 14 + sites/target/app.py | 1853 +++++++++++++++++ sites/target/requirements.txt | 4 + sites/target/seed_data.py | 692 ++++++ sites/target/static/css/main.css | 1314 ++++++++++++ sites/target/static/js/main.js | 2 + sites/target/tasks.jsonl | 20 + sites/target/templates/_account_nav.html | 10 + sites/target/templates/_checkout_steps.html | 27 + sites/target/templates/_pagination.html | 33 + sites/target/templates/_product_cards.html | 44 + sites/target/templates/account.html | 87 + sites/target/templates/account_edit.html | 52 + sites/target/templates/account_orders.html | 28 + sites/target/templates/account_rewards.html | 32 + sites/target/templates/account_support.html | 37 + sites/target/templates/base.html | 133 ++ sites/target/templates/cart.html | 65 + sites/target/templates/categories.html | 32 + .../templates/checkout_confirmation.html | 40 + sites/target/templates/checkout_mode.html | 48 + sites/target/templates/checkout_payment.html | 29 + sites/target/templates/checkout_pickup.html | 45 + sites/target/templates/checkout_review.html | 47 + sites/target/templates/checkout_shipping.html | 52 + sites/target/templates/compare.html | 78 + sites/target/templates/deals.html | 43 + sites/target/templates/home.html | 133 ++ sites/target/templates/login.html | 27 + sites/target/templates/order_detail.html | 55 + sites/target/templates/order_lookup.html | 29 + sites/target/templates/product_detail.html | 207 ++ sites/target/templates/products.html | 97 + sites/target/templates/register.html | 54 + sites/target/templates/search.html | 129 ++ sites/target/templates/store_detail.html | 63 + sites/target/templates/stores.html | 35 + sites/target/templates/support.html | 33 + sites/target/templates/support_article.html | 30 + sites/target/templates/support_contact.html | 33 + sites/target/templates/wishlist.html | 48 + sites/target/verify/README.md | 72 + sites/target/verify/verify_0.py | 73 + sites/target/verify/verify_1.py | 66 + sites/target/verify/verify_10.py | 83 + sites/target/verify/verify_11.py | 73 + sites/target/verify/verify_12.py | 83 + sites/target/verify/verify_13.py | 50 + sites/target/verify/verify_14.py | 83 + sites/target/verify/verify_15.py | 81 + sites/target/verify/verify_16.py | 51 + sites/target/verify/verify_17.py | 102 + sites/target/verify/verify_18.py | 84 + sites/target/verify/verify_19.py | 83 + sites/target/verify/verify_2.py | 62 + sites/target/verify/verify_3.py | 54 + sites/target/verify/verify_4.py | 50 + sites/target/verify/verify_5.py | 54 + sites/target/verify/verify_6.py | 68 + sites/target/verify/verify_7.py | 55 + sites/target/verify/verify_8.py | 48 + sites/target/verify/verify_9.py | 47 + sites/target/verify/verify_lib.py | 423 ++++ websyn_start.sh | 4 +- 66 files changed, 7683 insertions(+), 4 deletions(-) create mode 100644 sites/target/_health.py create mode 100644 sites/target/app.py create mode 100644 sites/target/requirements.txt create mode 100644 sites/target/seed_data.py create mode 100644 sites/target/static/css/main.css create mode 100644 sites/target/static/js/main.js create mode 100644 sites/target/tasks.jsonl create mode 100644 sites/target/templates/_account_nav.html create mode 100644 sites/target/templates/_checkout_steps.html create mode 100644 sites/target/templates/_pagination.html create mode 100644 sites/target/templates/_product_cards.html create mode 100644 sites/target/templates/account.html create mode 100644 sites/target/templates/account_edit.html create mode 100644 sites/target/templates/account_orders.html create mode 100644 sites/target/templates/account_rewards.html create mode 100644 sites/target/templates/account_support.html create mode 100644 sites/target/templates/base.html create mode 100644 sites/target/templates/cart.html create mode 100644 sites/target/templates/categories.html create mode 100644 sites/target/templates/checkout_confirmation.html create mode 100644 sites/target/templates/checkout_mode.html create mode 100644 sites/target/templates/checkout_payment.html create mode 100644 sites/target/templates/checkout_pickup.html create mode 100644 sites/target/templates/checkout_review.html create mode 100644 sites/target/templates/checkout_shipping.html create mode 100644 sites/target/templates/compare.html create mode 100644 sites/target/templates/deals.html create mode 100644 sites/target/templates/home.html create mode 100644 sites/target/templates/login.html create mode 100644 sites/target/templates/order_detail.html create mode 100644 sites/target/templates/order_lookup.html create mode 100644 sites/target/templates/product_detail.html create mode 100644 sites/target/templates/products.html create mode 100644 sites/target/templates/register.html create mode 100644 sites/target/templates/search.html create mode 100644 sites/target/templates/store_detail.html create mode 100644 sites/target/templates/stores.html create mode 100644 sites/target/templates/support.html create mode 100644 sites/target/templates/support_article.html create mode 100644 sites/target/templates/support_contact.html create mode 100644 sites/target/templates/wishlist.html create mode 100644 sites/target/verify/README.md create mode 100644 sites/target/verify/verify_0.py create mode 100644 sites/target/verify/verify_1.py create mode 100644 sites/target/verify/verify_10.py create mode 100644 sites/target/verify/verify_11.py create mode 100644 sites/target/verify/verify_12.py create mode 100644 sites/target/verify/verify_13.py create mode 100644 sites/target/verify/verify_14.py create mode 100644 sites/target/verify/verify_15.py create mode 100644 sites/target/verify/verify_16.py create mode 100644 sites/target/verify/verify_17.py create mode 100644 sites/target/verify/verify_18.py create mode 100644 sites/target/verify/verify_19.py create mode 100644 sites/target/verify/verify_2.py create mode 100644 sites/target/verify/verify_3.py create mode 100644 sites/target/verify/verify_4.py create mode 100644 sites/target/verify/verify_5.py create mode 100644 sites/target/verify/verify_6.py create mode 100644 sites/target/verify/verify_7.py create mode 100644 sites/target/verify/verify_8.py create mode 100644 sites/target/verify/verify_9.py create mode 100644 sites/target/verify/verify_lib.py diff --git a/Dockerfile b/Dockerfile index 1e86b1d0..ad6d5d9f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,6 +33,6 @@ COPY control_server.py /opt/control_server.py COPY site_runner.py /opt/site_runner.py RUN chmod +x /opt/websyn_start.sh -EXPOSE 8101 40000-40015 +EXPOSE 8101 40000-40016 CMD ["/opt/websyn_start.sh"] diff --git a/control_server.py b/control_server.py index 4b6b995e..e324056f 100644 --- a/control_server.py +++ b/control_server.py @@ -26,7 +26,7 @@ 'allrecipes', 'amazon', 'apple', 'arxiv', 'bbc_news', 'booking', 'github', 'google_flights', 'google_map', 'google_search', 'huggingface', 'wolfram_alpha', 'cambridge_dictionary', - 'coursera', 'espn', 'merriam_webster', + 'coursera', 'espn', 'merriam_webster', 'target', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' diff --git a/sites/target/_health.py b/sites/target/_health.py new file mode 100644 index 00000000..efe4b770 --- /dev/null +++ b/sites/target/_health.py @@ -0,0 +1,14 @@ +"""Per-site health probe for the Target mirror.""" + +from app import Product, Store, app + + +def health(): + with app.app_context(): + return { + "ok": True, + "site": "target", + "products": Product.query.count(), + "stores": Store.query.count(), + } + diff --git a/sites/target/app.py b/sites/target/app.py new file mode 100644 index 00000000..419c4bba --- /dev/null +++ b/sites/target/app.py @@ -0,0 +1,1853 @@ +"""Target demo mirror for WebHarbor.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import shutil +from datetime import datetime +from pathlib import Path +from typing import Any + +from flask import ( + Flask, + abort, + flash, + jsonify, + redirect, + render_template, + request, + session, + url_for, +) +from flask_login import ( + LoginManager, + UserMixin, + current_user, + login_required, + login_user, + logout_user, +) +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import or_ + + +SITE_SLUG = "target" +SITE_NAME = "Target" +SITE_PORT = 40016 +BENCHMARK_PASSWORD = "TestPass123!" +BASE_DIR = Path(__file__).resolve().parent +INSTANCE_DIR = BASE_DIR / "instance" +SEED_DIR = BASE_DIR / "instance_seed" +STATIC_DIR = BASE_DIR / "static" +IMAGE_DIR = STATIC_DIR / "images" +RUNTIME_DB_PATH = INSTANCE_DIR / "target.db" +SEED_DB_PATH = SEED_DIR / "target.db" +PASSWORD_NAMESPACE = "target-webharbor-demo" + + +def _ensure_dirs() -> None: + INSTANCE_DIR.mkdir(parents=True, exist_ok=True) + SEED_DIR.mkdir(parents=True, exist_ok=True) + IMAGE_DIR.mkdir(parents=True, exist_ok=True) + + +_ensure_dirs() + + +app = Flask(__name__, instance_path=str(INSTANCE_DIR)) +app.config["SECRET_KEY"] = "target-demo-session-key" +app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{RUNTIME_DB_PATH}" +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + +db = SQLAlchemy(app) +login_manager = LoginManager(app) +login_manager.login_view = "login" +login_manager.login_message = "Sign in with a benchmark account to use carts, orders, and rewards." + + +def stable_password_hash(raw_password: str) -> str: + digest = hashlib.sha256() + digest.update(f"{PASSWORD_NAMESPACE}:{raw_password}".encode("utf-8")) + return digest.hexdigest() + + +def load_json(raw: str | None, default: Any) -> Any: + if not raw: + return default + try: + return json.loads(raw) + except json.JSONDecodeError: + return default + + +def dump_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False) + + +def slugify(value: str) -> str: + cleaned = [] + for char in value.lower(): + if char.isalnum(): + cleaned.append(char) + elif cleaned and cleaned[-1] != "-": + cleaned.append("-") + return "".join(cleaned).strip("-") + + +class TimestampMixin: + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + +class User(db.Model, UserMixin, TimestampMixin): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(120), unique=True, nullable=False, index=True) + full_name = db.Column(db.String(120), nullable=False) + password_hash = db.Column(db.String(64), nullable=False) + phone = db.Column(db.String(32), default="") + city = db.Column(db.String(80), default="") + state = db.Column(db.String(40), default="") + preferred_store_slug = db.Column(db.String(80), default="") + member_tier = db.Column(db.String(32), default="Target Circle 360") + rewards_member_id = db.Column(db.String(40), default="") + + cart_items = db.relationship("CartItem", back_populates="user", cascade="all, delete-orphan") + orders = db.relationship("Order", back_populates="user", cascade="all, delete-orphan") + wishlist_items = db.relationship("WishlistItem", back_populates="user", cascade="all, delete-orphan") + compare_items = db.relationship("CompareItem", back_populates="user", cascade="all, delete-orphan") + support_tickets = db.relationship("SupportTicket", back_populates="user", cascade="all, delete-orphan") + reward_account = db.relationship("RewardAccount", back_populates="user", uselist=False, cascade="all, delete-orphan") + reward_activities = db.relationship("RewardActivity", back_populates="user", cascade="all, delete-orphan") + + def set_password(self, raw_password: str) -> None: + self.password_hash = stable_password_hash(raw_password) + + def check_password(self, raw_password: str) -> bool: + return self.password_hash == stable_password_hash(raw_password) + + +class Brand(db.Model): + __tablename__ = "brands" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), nullable=False) + slug = db.Column(db.String(80), unique=True, nullable=False, index=True) + accent_color = db.Column(db.String(16), default="#cc0000") # Target red + + products = db.relationship("Product", back_populates="brand") + + +class Category(db.Model): + __tablename__ = "categories" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False) + slug = db.Column(db.String(80), unique=True, nullable=False, index=True) + section = db.Column(db.String(80), default="") + description = db.Column(db.Text, default="") + hero_title = db.Column(db.String(160), default="") + image_path = db.Column(db.String(255), default="") + + products = db.relationship("Product", back_populates="category") + + +class Product(db.Model): + __tablename__ = "products" + + id = db.Column(db.Integer, primary_key=True) + sku = db.Column(db.String(24), unique=True, nullable=False, index=True) + slug = db.Column(db.String(160), unique=True, nullable=False, index=True) + name = db.Column(db.String(255), nullable=False) + short_description = db.Column(db.Text, default="") + long_description = db.Column(db.Text, default="") + price = db.Column(db.Float, nullable=False) + list_price = db.Column(db.Float, nullable=False, default=0.0) + rating = db.Column(db.Float, default=4.5) + review_count = db.Column(db.Integer, default=0) + # Real per-attribute guest ratings (comfort, quality, value, ...) and the + # "% would recommend" figure, both scraped from the live PDP. They only + # appear on the detail page, never on a search card. + secondary_ratings_json = db.Column(db.Text, default="{}") + percent_recommended = db.Column(db.Integer) + availability_status = db.Column(db.String(40), default="In stock") + pickup_eligible = db.Column(db.Boolean, default=True) + delivery_eligible = db.Column(db.Boolean, default=True) + featured = db.Column(db.Boolean, default=False) + deal_badge = db.Column(db.String(80), default="") + image_path = db.Column(db.String(255), default="") + highlights_json = db.Column(db.Text, default="[]") + specs_json = db.Column(db.Text, default="[]") + tags_json = db.Column(db.Text, default="[]") + search_keywords = db.Column(db.Text, default="") + stock_count = db.Column(db.Integer, default=0) + category_id = db.Column(db.Integer, db.ForeignKey("categories.id"), nullable=False) + brand_id = db.Column(db.Integer, db.ForeignKey("brands.id"), nullable=False) + + category = db.relationship("Category", back_populates="products") + brand = db.relationship("Brand", back_populates="products") + reviews = db.relationship("Review", back_populates="product", cascade="all, delete-orphan") + inventory_rows = db.relationship("StoreInventory", back_populates="product", cascade="all, delete-orphan") + cart_items = db.relationship("CartItem", back_populates="product", cascade="all, delete-orphan") + wishlist_items = db.relationship("WishlistItem", back_populates="product", cascade="all, delete-orphan") + compare_items = db.relationship("CompareItem", back_populates="product", cascade="all, delete-orphan") + protection_plans = db.relationship("ProtectionPlan", back_populates="product", cascade="all, delete-orphan") + order_items = db.relationship("OrderItem", back_populates="product") + deals = db.relationship("Deal", back_populates="product") + + def highlights(self) -> list[str]: + return load_json(self.highlights_json, []) + + def specs(self) -> list[dict[str, Any]]: + return load_json(self.specs_json, []) + + def tags(self) -> list[str]: + return load_json(self.tags_json, []) + + def secondary_ratings(self) -> dict[str, float]: + return load_json(self.secondary_ratings_json, {}) + + def discount_percent(self) -> int: + if self.list_price > self.price > 0: + return int(round((self.list_price - self.price) / self.list_price * 100)) + return 0 + + def support_search_blob(self) -> str: + parts = [self.name, self.short_description, self.long_description, self.search_keywords] + parts.extend(self.tags()) + parts.extend(self.highlights()) + return " ".join(part for part in parts if part).lower() + + +class Store(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(80), unique=True, nullable=False, index=True) + name = db.Column(db.String(120), nullable=False) + city = db.Column(db.String(80), nullable=False) + state = db.Column(db.String(40), nullable=False) + address = db.Column(db.String(160), nullable=False) + phone = db.Column(db.String(24), default="") + hours_json = db.Column(db.Text, default="[]") + amenities_json = db.Column(db.Text, default="[]") + services_json = db.Column(db.Text, default="[]") + hero_copy = db.Column(db.Text, default="") + image_path = db.Column(db.String(255), default="") + + inventory_rows = db.relationship("StoreInventory", back_populates="store", cascade="all, delete-orphan") + pickup_slots = db.relationship("PickupSlot", back_populates="store", cascade="all, delete-orphan") + orders = db.relationship("Order", back_populates="store") + + def hours(self) -> list[str]: + return load_json(self.hours_json, []) + + def amenities(self) -> list[str]: + return load_json(self.amenities_json, []) + + def services(self) -> list[str]: + return load_json(self.services_json, []) + + +class StoreInventory(db.Model): + __tablename__ = "store_inventory" + + id = db.Column(db.Integer, primary_key=True) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) + product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) + quantity = db.Column(db.Integer, default=0) + pickup_window = db.Column(db.String(120), default="") + aisle = db.Column(db.String(40), default="") + + store = db.relationship("Store", back_populates="inventory_rows") + product = db.relationship("Product", back_populates="inventory_rows") + + +class Review(db.Model, TimestampMixin): + __tablename__ = "reviews" + + id = db.Column(db.Integer, primary_key=True) + product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) + author_name = db.Column(db.String(80), nullable=False) + title = db.Column(db.String(140), nullable=False) + body = db.Column(db.Text, nullable=False) + rating = db.Column(db.Integer, nullable=False) + verified = db.Column(db.Boolean, default=True) + + product = db.relationship("Product", back_populates="reviews") + + +class CartItem(db.Model, TimestampMixin): + __tablename__ = "cart_items" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) + quantity = db.Column(db.Integer, default=1) + fulfillment_method = db.Column(db.String(24), default="delivery") + store_id = db.Column(db.Integer, db.ForeignKey("stores.id")) + delivery_option_id = db.Column(db.Integer, db.ForeignKey("delivery_options.id")) + protection_plan_id = db.Column(db.Integer, db.ForeignKey("protection_plans.id")) + + user = db.relationship("User", back_populates="cart_items") + product = db.relationship("Product", back_populates="cart_items") + store = db.relationship("Store") + delivery_option = db.relationship("DeliveryOption") + protection_plan = db.relationship("ProtectionPlan") + + +class WishlistItem(db.Model, TimestampMixin): + __tablename__ = "wishlist_items" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) + + user = db.relationship("User", back_populates="wishlist_items") + product = db.relationship("Product", back_populates="wishlist_items") + + +class CompareItem(db.Model, TimestampMixin): + __tablename__ = "compare_items" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) + + user = db.relationship("User", back_populates="compare_items") + product = db.relationship("Product", back_populates="compare_items") + + +class ProtectionPlan(db.Model): + __tablename__ = "protection_plans" + + id = db.Column(db.Integer, primary_key=True) + product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) + name = db.Column(db.String(120), nullable=False) + years = db.Column(db.Integer, default=2) + price = db.Column(db.Float, nullable=False) + coverage_summary = db.Column(db.Text, default="") + accidental = db.Column(db.Boolean, default=False) + priority_support = db.Column(db.Boolean, default=True) + + product = db.relationship("Product", back_populates="protection_plans") + + +class SupportArticle(db.Model): + __tablename__ = "support_articles" + + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(120), unique=True, nullable=False, index=True) + title = db.Column(db.String(180), nullable=False) + topic = db.Column(db.String(80), default="") + summary = db.Column(db.Text, default="") + body = db.Column(db.Text, default="") + upstream_url = db.Column(db.String(255), default="") + keywords_json = db.Column(db.Text, default="[]") + + def keywords(self) -> list[str]: + return load_json(self.keywords_json, []) + + +class SupportTicket(db.Model, TimestampMixin): + __tablename__ = "support_tickets" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + subject = db.Column(db.String(180), nullable=False) + status = db.Column(db.String(40), default="Resolved") + channel = db.Column(db.String(40), default="Chat") + summary = db.Column(db.Text, default="") + + user = db.relationship("User", back_populates="support_tickets") + + +class Deal(db.Model): + __tablename__ = "deals" + + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(120), unique=True, nullable=False, index=True) + title = db.Column(db.String(180), nullable=False) + subtitle = db.Column(db.Text, default="") + badge = db.Column(db.String(80), default="Member Deal") + discount_percent = db.Column(db.Integer, default=0) + ends_label = db.Column(db.String(80), default="") + category_slug = db.Column(db.String(80), default="") + product_id = db.Column(db.Integer, db.ForeignKey("products.id")) + + product = db.relationship("Product", back_populates="deals") + + +class RewardAccount(db.Model): + __tablename__ = "reward_accounts" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), unique=True, nullable=False) + member_id = db.Column(db.String(40), nullable=False) + points_balance = db.Column(db.Integer, default=0) + tier = db.Column(db.String(40), default="Target Circle 360") + available_certificates = db.Column(db.Integer, default=0) + + user = db.relationship("User", back_populates="reward_account") + + +class RewardActivity(db.Model): + __tablename__ = "reward_activities" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + points_delta = db.Column(db.Integer, nullable=False) + title = db.Column(db.String(140), nullable=False) + note = db.Column(db.Text, default="") + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + user = db.relationship("User", back_populates="reward_activities") + + +class DeliveryOption(db.Model): + __tablename__ = "delivery_options" + + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(80), unique=True, nullable=False, index=True) + title = db.Column(db.String(120), nullable=False) + description = db.Column(db.Text, default="") + fee = db.Column(db.Float, default=0.0) + eta_label = db.Column(db.String(120), default="") + + +class PickupSlot(db.Model): + __tablename__ = "pickup_slots" + + id = db.Column(db.Integer, primary_key=True) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) + slot_code = db.Column(db.String(40), unique=True, nullable=False) + day_label = db.Column(db.String(80), nullable=False) + time_window = db.Column(db.String(80), nullable=False) + available_capacity = db.Column(db.Integer, default=0) + + store = db.relationship("Store", back_populates="pickup_slots") + + +class Order(db.Model): + __tablename__ = "orders" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id")) + order_number = db.Column(db.String(32), unique=True, nullable=False, index=True) + email = db.Column(db.String(120), nullable=False) + status = db.Column(db.String(60), default="Preparing") + subtotal = db.Column(db.Float, default=0.0) + tax = db.Column(db.Float, default=0.0) + total = db.Column(db.Float, default=0.0) + fulfillment_method = db.Column(db.String(32), default="delivery") + store_id = db.Column(db.Integer, db.ForeignKey("stores.id")) + delivery_option_id = db.Column(db.Integer, db.ForeignKey("delivery_options.id")) + shipping_name = db.Column(db.String(120), default="") + shipping_street = db.Column(db.String(160), default="") + shipping_city = db.Column(db.String(80), default="") + shipping_state = db.Column(db.String(40), default="") + shipping_zip = db.Column(db.String(20), default="") + payment_brand = db.Column(db.String(40), default="Demo Visa") + payment_last4 = db.Column(db.String(4), default="1111") + confirmation_note = db.Column(db.Text, default="") + pickup_slot_label = db.Column(db.String(120), default="") + placed_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + user = db.relationship("User", back_populates="orders") + store = db.relationship("Store", back_populates="orders") + delivery_option = db.relationship("DeliveryOption") + items = db.relationship("OrderItem", back_populates="order", cascade="all, delete-orphan") + payment = db.relationship("PaymentMock", back_populates="order", uselist=False, cascade="all, delete-orphan") + + def item_count(self) -> int: + return sum(item.quantity for item in self.items) + + +class OrderItem(db.Model): + __tablename__ = "order_items" + + id = db.Column(db.Integer, primary_key=True) + order_id = db.Column(db.Integer, db.ForeignKey("orders.id"), nullable=False) + product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) + item_name = db.Column(db.String(255), nullable=False) + quantity = db.Column(db.Integer, default=1) + unit_price = db.Column(db.Float, default=0.0) + protection_plan_name = db.Column(db.String(140), default="") + + order = db.relationship("Order", back_populates="items") + product = db.relationship("Product", back_populates="order_items") + + +class PaymentMock(db.Model): + __tablename__ = "payment_mocks" + + id = db.Column(db.Integer, primary_key=True) + order_id = db.Column(db.Integer, db.ForeignKey("orders.id"), unique=True, nullable=False) + amount = db.Column(db.Float, nullable=False) + card_label = db.Column(db.String(80), default="Demo Visa") + auth_status = db.Column(db.String(40), default="Approved") + approval_code = db.Column(db.String(20), default="") + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + order = db.relationship("Order", back_populates="payment") + + +class SearchLog(db.Model): + __tablename__ = "search_logs" + + id = db.Column(db.Integer, primary_key=True) + query = db.Column(db.String(255), nullable=False) + scope = db.Column(db.String(40), default="products") + user_id = db.Column(db.Integer, db.ForeignKey("users.id")) + result_count = db.Column(db.Integer, default=0) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + +@login_manager.user_loader +def load_user(user_id: str) -> User | None: + return db.session.get(User, int(user_id)) + + +@app.template_filter("currency") +def currency_filter(value: float) -> str: + return f"${value:,.2f}" + + +@app.template_filter("stars") +def star_filter(value: float) -> str: + return f"{value:.1f}" + + +@app.template_filter("paragraphs") +def paragraphs_filter(value: str) -> list[str]: + return [chunk.strip() for chunk in value.split("\n") if chunk.strip()] + + +# Order of the mega-menu's top level, mirroring target.com's own ordering. +# Sections not listed here fall to the end, alphabetically. +SECTION_ORDER = [ + "Clothing, Shoes & Accessories", + "Home & Decor", + "Kitchen & Dining", + "Grocery", + "Household Essentials", + "Baby", + "Beauty & Personal Care", + "Toys & Video Games", + "Sports & Outdoors", + "Electronics", + "Pets", +] + + +def nav_categories() -> list[Category]: + return Category.query.order_by(Category.name.asc()).limit(8).all() + + +def nav_sections() -> list[tuple[str, list[Category]]]: + """Departments grouped under their mega-menu section. + + target.com nests departments two levels deep (Clothing, Shoes & + Accessories > Women's Clothing), so a flat department list would + misrepresent the real navigation. + """ + grouped: dict[str, list[Category]] = {} + for category in Category.query.order_by(Category.name.asc()).all(): + grouped.setdefault(category.section, []).append(category) + + def rank(section: str) -> tuple[int, str]: + try: + return (SECTION_ORDER.index(section), "") + except ValueError: + return (len(SECTION_ORDER), section) + + return [(name, grouped[name]) for name in sorted(grouped, key=rank)] + + +def get_preferred_store() -> Store | None: + if current_user.is_authenticated and current_user.preferred_store_slug: + return Store.query.filter_by(slug=current_user.preferred_store_slug).first() + return Store.query.order_by(Store.city.asc()).first() + + +def get_compare_products() -> list[Product]: + if current_user.is_authenticated: + return [item.product for item in current_user.compare_items[:4]] + compare_skus = session.get("compare_skus", []) + if not compare_skus: + return [] + products = Product.query.filter(Product.sku.in_(compare_skus[:4])).all() + order = {sku: index for index, sku in enumerate(compare_skus[:4])} + return sorted(products, key=lambda product: order.get(product.sku, 999)) + + +def get_cart_items() -> list[CartItem]: + if not current_user.is_authenticated: + return [] + return ( + CartItem.query.filter_by(user_id=current_user.id) + .order_by(CartItem.created_at.desc()) + .all() + ) + + +def cart_totals(cart_items: list[CartItem]) -> dict[str, float]: + subtotal = 0.0 + for item in cart_items: + plan_price = item.protection_plan.price if item.protection_plan else 0.0 + subtotal += (item.product.price + plan_price) * item.quantity + return {"subtotal": round(subtotal, 2), "count": sum(item.quantity for item in cart_items)} + + +def pickup_windows() -> list[str]: + """The distinct pickup windows, e.g. "Tomorrow 9:00 AM - 11:00 AM". + + Every store keeps its own PickupSlot rows but they all offer the same + times, so the picker shows each window once instead of once per store. + Ordered so Today's slots come before Tomorrow's. + """ + seen: list[str] = [] + for slot in PickupSlot.query.order_by(PickupSlot.id.asc()).all(): + label = f"{slot.day_label} {slot.time_window}" + if label not in seen: + seen.append(label) + return sorted(seen, key=lambda w: (0 if w.startswith("Today") else 1, w)) + + +def resolve_pickup_slot(store_id: int, window: str) -> "PickupSlot | None": + """Map a shared window label back to that store's own slot row.""" + for slot in PickupSlot.query.filter_by(store_id=store_id).all(): + if f"{slot.day_label} {slot.time_window}" == window: + return slot + return None + + +def get_checkout_state() -> dict[str, Any]: + checkout = session.get("target_checkout", {}) + if not isinstance(checkout, dict): + checkout = {} + return checkout + + +def save_checkout_state(checkout: dict[str, Any]) -> None: + session["target_checkout"] = checkout + session.modified = True + + +def clear_checkout_state() -> None: + session.pop("target_checkout", None) + session.modified = True + + +def merge_compare_session_into_user() -> None: + compare_skus = session.pop("compare_skus", []) + if not compare_skus or not current_user.is_authenticated: + return + existing = {item.product.sku for item in current_user.compare_items} + for sku in compare_skus[:4]: + if sku in existing: + continue + product = Product.query.filter_by(sku=sku).first() + if product: + db.session.add(CompareItem(user_id=current_user.id, product_id=product.id)) + db.session.commit() + + +@app.context_processor +def inject_global_context() -> dict[str, Any]: + cart_items = get_cart_items() + compare_products = get_compare_products() + reward_points = 0 + if current_user.is_authenticated and current_user.reward_account: + reward_points = current_user.reward_account.points_balance + return { + "site_name": SITE_NAME, + "benchmark_password": BENCHMARK_PASSWORD, + "nav_categories": nav_categories(), + "nav_sections": nav_sections(), + "cart_item_count": cart_totals(cart_items)["count"], + "compare_count": len(compare_products), + "preferred_store": get_preferred_store(), + "reward_points": reward_points, + } + + +def log_search(query: str, scope: str, result_count: int) -> None: + if not query: + return + db.session.add( + SearchLog( + query=query, + scope=scope, + user_id=current_user.id if current_user.is_authenticated else None, + result_count=result_count, + ) + ) + db.session.commit() + + +def search_tokens(q: str) -> list[str]: + """Split a query into lowercase tokens (drops 1-char noise).""" + return [t for t in re.split(r"[^a-z0-9]+", q.lower()) if len(t) > 1] + + +def token_match(tokens: list[str]): + """Token-overlap search per the WebHarbor guide: scored relevance, NOT strict AND. + + Returns (filter_condition, score_expression). A product qualifies when ANY + token matches (score > 0); the score counts how many tokens matched so the + best overlaps rank first. This is what makes multi-word queries like + "dell laptop" or "sony headphones" work instead of returning nothing. + """ + conds, score_terms = [], [] + for tok in tokens: + like = f"%{tok}%" + cond = or_( + db.func.lower(Product.name).like(like), + db.func.lower(Product.search_keywords).like(like), + db.func.lower(Brand.name).like(like), + db.func.lower(Category.name).like(like), + ) + conds.append(cond) + score_terms.append(db.case((cond, 1), else_=0)) + score = score_terms[0] + for extra in score_terms[1:]: + score = score + extra + return or_(*conds), score + + +PRODUCTS_PER_PAGE = 24 +CART_MAX_QTY = 5 + + +def paginate_products(products_query): + """Page a product listing the way target.com does — 24 tiles per page. + + The catalog holds ~1.3k products, so rendering a listing unpaged produced a + single multi-megabyte page that no agent could work with. + """ + try: + page = max(1, int(request.args.get("page", 1))) + except ValueError: + page = 1 + return products_query.paginate(page=page, per_page=PRODUCTS_PER_PAGE, error_out=False) + + +def apply_product_filters(query, exclude: str | None = None): + """Apply the facet rail (brand / price / rating / fulfilment) to a query. + + `exclude` skips one facet, which is how each facet's own option list is + built — see facet_options(). Split out of product_query_from_filters so + /search can offer the same facets a department listing does. + """ + category_slug = request.args.get("category", "").strip() + if category_slug and exclude != "category": + query = query.filter(Category.slug == category_slug) + + brand_slug = "" if exclude == "brand" else request.args.get("brand", "").strip() + if brand_slug: + query = query.filter(Brand.slug == brand_slug) + + try: + min_price = float(request.args.get("min_price", "") or 0) + if min_price: + query = query.filter(Product.price >= min_price) + except ValueError: + min_price = None + + try: + max_price = float(request.args.get("max_price", "") or 0) + if max_price: + query = query.filter(Product.price <= max_price) + except ValueError: + max_price = None + + rating_filter = request.args.get("rating", "").strip() + if rating_filter: + try: + rating_value = float(rating_filter) + query = query.filter(Product.rating >= rating_value) + except ValueError: + pass + + availability = request.args.get("availability", "").strip() + if availability == "in-stock": + query = query.filter(Product.stock_count > 0) + elif availability == "pickup": + query = query.filter(Product.pickup_eligible.is_(True)) + + if request.args.get("pickup") == "1": + query = query.filter(Product.pickup_eligible.is_(True)) + if request.args.get("delivery") == "1": + query = query.filter(Product.delivery_eligible.is_(True)) + # target.com's rail carries a "Deals" facet; ours is derived from the real + # list_price > price data rather than a flag. + if request.args.get("deals") == "1": + query = query.filter(Product.list_price > Product.price) + + return query + + +def facet_options(model, search_cond, exclude: str): + """Distinct `model` rows still reachable once every facet EXCEPT `exclude` + is applied to the current search. + + Returning options that would yield zero results is the bug this exists to + prevent: with the brand list built from the unfiltered match set, picking + Furniture still offered Ninja, and that pair matches nothing. + """ + reachable = Product.query.join(Brand).join(Category).filter(search_cond) + reachable = apply_product_filters(reachable, exclude=exclude) + return (model.query.join(Product) + .filter(Product.id.in_(reachable.with_entities(Product.id))) + .order_by(model.name.asc()).distinct().all()) + + +def apply_product_sort(query, sort: str): + if sort == "price-asc": + return query.order_by(Product.price.asc(), Product.rating.desc()) + if sort == "price-desc": + return query.order_by(Product.price.desc(), Product.rating.desc()) + if sort == "rating": + return query.order_by(Product.rating.desc(), Product.review_count.desc()) + if sort == "newest": + return query.order_by(Product.id.desc()) + return query.order_by(Product.featured.desc(), Product.rating.desc(), + Product.review_count.desc()) + + +def product_query_from_filters(category_slug: str | None = None): + query = Product.query.join(Brand).join(Category) + if category_slug: + query = query.filter(Category.slug == category_slug) + + q = request.args.get("q", "").strip() + if q: + tokens = search_tokens(q) + if tokens: + cond, score = token_match(tokens) + query = query.filter(cond).order_by(score.desc()) + + query = apply_product_filters(query) + query = apply_product_sort(query, request.args.get("sort", "featured")) + return query, q + + +def order_accessible(order: Order) -> bool: + if current_user.is_authenticated and order.user_id == current_user.id: + return True + return session.get("target_lookup_order") == order.order_number + + +def build_checkout_summary(cart_items: list[CartItem], checkout: dict[str, Any]) -> dict[str, Any]: + totals = cart_totals(cart_items) + subtotal = totals["subtotal"] + mode = checkout.get("mode", "delivery") + delivery_option = None + store = None + pickup_slot = None + shipping_fee = 0.0 + + if mode == "pickup": + if checkout.get("store_id"): + store = db.session.get(Store, int(checkout["store_id"])) + if checkout.get("slot_id"): + pickup_slot = db.session.get(PickupSlot, int(checkout["slot_id"])) + else: + mode = "delivery" + if checkout.get("delivery_option_id"): + delivery_option = db.session.get(DeliveryOption, int(checkout["delivery_option_id"])) + shipping_fee = delivery_option.fee if delivery_option else 0.0 + + tax = round((subtotal + shipping_fee) * 0.086, 2) + total = round(subtotal + shipping_fee + tax, 2) + + return { + "subtotal": subtotal, + "shipping_fee": shipping_fee, + "tax": tax, + "total": total, + "mode": mode, + "delivery_option": delivery_option, + "store": store, + "pickup_slot": pickup_slot, + } + + +def require_cart_items() -> list[CartItem]: + cart_items = get_cart_items() + if not cart_items: + flash("Your cart is empty. Add a demo product before checkout.", "warning") + raise RuntimeError("empty-cart") + return cart_items + + +@app.route("/") +@app.route("/home") +def home(): + featured_products = Product.query.filter_by(featured=True).order_by(Product.rating.desc()).limit(8).all() + deal_cards = Deal.query.order_by(Deal.discount_percent.desc(), Deal.title.asc()).limit(6).all() + stores = Store.query.order_by(Store.city.asc()).limit(4).all() + support_articles = SupportArticle.query.order_by(SupportArticle.id.asc()).limit(4).all() + return render_template( + "home.html", + featured_products=featured_products, + deal_cards=deal_cards, + stores=stores, + support_articles=support_articles, + categories=Category.query.order_by(Category.name.asc()).all(), + ) + + +@app.route("/categories") +def categories_page(): + categories = Category.query.order_by(Category.name.asc()).all() + return render_template("categories.html", categories=categories) + + +@app.route("/category/") +def category_page(category_slug: str): + category = Category.query.filter_by(slug=category_slug).first_or_404() + products_query, q = product_query_from_filters(category_slug) + pagination = paginate_products(products_query) + brands = Brand.query.join(Product).filter(Product.category_id == category.id).order_by(Brand.name.asc()).distinct().all() + return render_template( + "products.html", + page_title=category.name, + page_description=category.description, + category=category, + products=pagination.items, + pagination=pagination, + brands=brands, + active_query=q, + ) + + +@app.route("/products") +def products_page(): + products_query, q = product_query_from_filters() + pagination = paginate_products(products_query) + log_search(q, "products", pagination.total) + return render_template( + "products.html", + page_title="Shop all products", + page_description="Synthetic products, deterministic stock, and fully local browsing for benchmark tasks.", + category=None, + products=pagination.items, + pagination=pagination, + brands=Brand.query.order_by(Brand.name.asc()).all(), + active_query=q, + ) + + +@app.route("/product/") +def product_page(sku: str): + product = Product.query.filter_by(sku=sku).first_or_404() + inventory_rows = ( + StoreInventory.query.filter_by(product_id=product.id) + .join(Store) + .order_by(StoreInventory.quantity.desc(), Store.city.asc()) + .limit(6) + .all() + ) + related_products = ( + Product.query.filter( + Product.category_id == product.category_id, + Product.id != product.id, + ) + .order_by(Product.rating.desc(), Product.review_count.desc()) + .limit(4) + .all() + ) + compare_skus = {item.sku for item in get_compare_products()} + wishlist_product_ids = set() + if current_user.is_authenticated: + wishlist_product_ids = {item.product_id for item in current_user.wishlist_items} + return render_template( + "product_detail.html", + product=product, + inventory_rows=inventory_rows, + related_products=related_products, + compare_skus=compare_skus, + wishlist_product_ids=wishlist_product_ids, + ) + + +@app.route("/compare") +def compare_page(): + products = get_compare_products() + spec_rows: list[tuple[str, list[str]]] = [] + if products: + labels: list[str] = [] + for product in products: + for section in product.specs(): + for spec in section.get("items", []): + label = spec.get("label", "") + if label and label not in labels: + labels.append(label) + for label in labels: + values = [] + for product in products: + value = "-" + for section in product.specs(): + for spec in section.get("items", []): + if spec.get("label") == label: + value = spec.get("value", "-") + values.append(value) + # A row only one product can fill is not a comparison — it is a + # column of dashes. Comparing a cookware set against three TVs + # produced ~30 such rows and buried the handful that mattered. + if sum(1 for v in values if v != "-") >= 2: + spec_rows.append((label, values)) + + # Protection plans are a real, scraped, per-product attribute and the + # thing shoppers most often weigh between similar items — but they live + # on their own table, so the spec loop above never sees them. + plan_labels: list[str] = [] + for product in products: + for plan in sorted(product.protection_plans, key=lambda p: p.years): + if plan.name not in plan_labels: + plan_labels.append(plan.name) + for plan_label in plan_labels: + values = [] + for product in products: + match = next((p for p in product.protection_plans + if p.name == plan_label), None) + values.append(f"${match.price:.2f}" if match else "-") + if sum(1 for v in values if v != "-") >= 2: + spec_rows.append((plan_label, values)) + + # Products from different departments legitimately share almost nothing; + # say so instead of rendering an empty table. + compared_categories = sorted({p.category.name for p in products}) + return render_template("compare.html", products=products, spec_rows=spec_rows, + compared_categories=compared_categories) + + +@app.route("/deals") +def deals_page(): + deals = Deal.query.order_by(Deal.discount_percent.desc(), Deal.title.asc()).all() + return render_template("deals.html", deals=deals) + + +@app.route("/stores") +def stores_page(): + q = request.args.get("q", "").strip().lower() + query = Store.query + if q: + q_like = f"%{q}%" + query = query.filter( + or_( + db.func.lower(Store.name).like(q_like), + db.func.lower(Store.city).like(q_like), + db.func.lower(Store.state).like(q_like), + db.func.lower(Store.address).like(q_like), + ) + ) + stores = query.order_by(Store.state.asc(), Store.city.asc()).all() + return render_template("stores.html", stores=stores, active_query=q) + + +@app.route("/stores/") +def store_page(store_slug: str): + store = Store.query.filter_by(slug=store_slug).first_or_404() + inventory_rows = ( + StoreInventory.query.filter_by(store_id=store.id) + .join(Product) + .order_by(StoreInventory.quantity.desc(), Product.rating.desc()) + .limit(10) + .all() + ) + return render_template("store_detail.html", store=store, inventory_rows=inventory_rows) + + +@app.route("/support") +@app.route("/help") +def support_page(): + q = request.args.get("q", "").strip().lower() + topic = request.args.get("topic", "").strip() + query = SupportArticle.query + if q: + q_like = f"%{q}%" + query = query.filter( + or_( + db.func.lower(SupportArticle.title).like(q_like), + db.func.lower(SupportArticle.summary).like(q_like), + db.func.lower(SupportArticle.body).like(q_like), + db.func.lower(SupportArticle.keywords_json).like(q_like), + ) + ) + if topic: + query = query.filter(SupportArticle.topic == topic) + articles = query.order_by(SupportArticle.title.asc()).all() + if q: + log_search(q, "support", len(articles)) + topics = sorted({article.topic for article in SupportArticle.query.all()}) + return render_template("support.html", articles=articles, active_query=q, topics=topics, active_topic=topic) + + +@app.route("/support/") +def support_article_page(article_slug: str): + article = SupportArticle.query.filter_by(slug=article_slug).first_or_404() + related_articles = ( + SupportArticle.query.filter( + SupportArticle.topic == article.topic, + SupportArticle.id != article.id, + ) + .order_by(SupportArticle.title.asc()) + .limit(4) + .all() + ) + return render_template("support_article.html", article=article, related_articles=related_articles) + + +@app.route("/search") +def search_page(): + q = request.args.get("q", "").strip() + product_results = [] + store_results = [] + article_results = [] + pagination = None + facet_brands: list[Brand] = [] + facet_categories: list[Category] = [] + if q: + q_like = f"%{q.lower()}%" + tokens = search_tokens(q) + if tokens: + cond, score = token_match(tokens) + # Same filter surface as a department listing — target.com's search + # results carry a facet rail, and without one a query can only be + # narrowed by retyping it. + products_query = Product.query.join(Brand).join(Category).filter(cond) + products_query = apply_product_filters(products_query) + sort = request.args.get("sort", "").strip() + if sort: + products_query = apply_product_sort(products_query, sort) + else: + products_query = products_query.order_by( + score.desc(), Product.featured.desc(), Product.rating.desc() + ) + pagination = paginate_products(products_query) + product_results = pagination.items + + # Facet options: each facet is computed with every OTHER facet + # applied, but not with itself. That is what makes the rail behave: + # * excluding itself -> after picking Keurig you can still switch + # to Ninja, instead of the dropdown collapsing to one option + # * applying the rest -> picking Furniture drops Ninja from the + # brand list, so the rail can no longer offer a combination + # that returns nothing + facet_brands = facet_options(Brand, cond, exclude="brand") + facet_categories = facet_options(Category, cond, exclude="category") + else: + product_results = [] + store_results = ( + Store.query.filter( + or_( + db.func.lower(Store.name).like(q_like), + db.func.lower(Store.city).like(q_like), + db.func.lower(Store.state).like(q_like), + ) + ) + .order_by(Store.city.asc()) + .limit(8) + .all() + ) + article_results = ( + SupportArticle.query.filter( + or_( + db.func.lower(SupportArticle.title).like(q_like), + db.func.lower(SupportArticle.summary).like(q_like), + db.func.lower(SupportArticle.body).like(q_like), + ) + ) + .order_by(SupportArticle.title.asc()) + .limit(8) + .all() + ) + log_search(q, "global", len(product_results) + len(store_results) + len(article_results)) + return render_template( + "search.html", + active_query=q, + product_results=product_results, + store_results=store_results, + article_results=article_results, + pagination=pagination, + # Facets are drawn from the matches themselves, so the rail never + # offers a brand or department that would return nothing. + facet_brands=facet_brands, + facet_categories=facet_categories, + ) + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if current_user.is_authenticated: + return redirect(url_for("account")) + + if request.method == "POST": + email = request.form.get("email", "").strip().lower() + password = request.form.get("password", "") + user = User.query.filter_by(email=email).first() + if not user or not user.check_password(password): + flash("That demo account/password combination was not recognized.", "danger") + else: + login_user(user) + merge_compare_session_into_user() + flash(f"Welcome back, {user.full_name}.", "success") + next_url = request.args.get("next") or url_for("account") + return redirect(next_url) + return render_template("login.html") + + +@app.route("/register", methods=["GET", "POST"]) +def register(): + if current_user.is_authenticated: + return redirect(url_for("account")) + + stores = Store.query.order_by(Store.city.asc()).all() + if request.method == "POST": + email = request.form.get("email", "").strip().lower() + full_name = request.form.get("full_name", "").strip() + password = request.form.get("password", "") + confirm_password = request.form.get("confirm_password", "") + preferred_store_slug = request.form.get("preferred_store_slug", "").strip() + + if not email or not full_name or not password: + flash("Please fill in your name, email, and password.", "warning") + elif password != confirm_password: + flash("The passwords did not match.", "warning") + elif User.query.filter_by(email=email).first(): + flash("That email is already registered in this demo mirror.", "warning") + else: + user = User( + email=email, + full_name=full_name, + phone=request.form.get("phone", "").strip(), + city=request.form.get("city", "").strip(), + state=request.form.get("state", "").strip(), + preferred_store_slug=preferred_store_slug, + member_tier="Target Circle", + rewards_member_id=f"TGTC-{100000 + User.query.count() + 1}", + ) + user.set_password(password) + db.session.add(user) + db.session.flush() + db.session.add( + RewardAccount( + user_id=user.id, + member_id=user.rewards_member_id, + points_balance=120, + tier=user.member_tier, + available_certificates=0, + ) + ) + db.session.commit() + login_user(user) + flash("Your local demo account is ready.", "success") + return redirect(url_for("account")) + + return render_template("register.html", stores=stores) + + +@app.route("/logout") +def logout(): + if current_user.is_authenticated: + logout_user() + flash("You have been signed out of the demo account.", "info") + return redirect(url_for("home")) + + +@app.route("/account") +@login_required +def account(): + recent_orders = Order.query.filter_by(user_id=current_user.id).order_by(Order.placed_at.desc()).limit(3).all() + recent_tickets = SupportTicket.query.filter_by(user_id=current_user.id).order_by(SupportTicket.created_at.desc()).limit(3).all() + wishlist_preview = current_user.wishlist_items[:4] + return render_template( + "account.html", + recent_orders=recent_orders, + recent_tickets=recent_tickets, + wishlist_preview=wishlist_preview, + reward_account=current_user.reward_account, + ) + + +@app.route("/account/edit", methods=["GET", "POST"]) +@login_required +def account_edit(): + """Profile editing — required by the mirror's route contract. + + Validates server-side rather than trusting the form: an agent submitting a + blank name or a malformed email must see the error, not a silent success. + """ + stores = Store.query.order_by(Store.city.asc()).all() + if request.method == "POST": + full_name = request.form.get("full_name", "").strip() + email = request.form.get("email", "").strip().lower() + phone = request.form.get("phone", "").strip() + city = request.form.get("city", "").strip() + state = request.form.get("state", "").strip() + store_slug = request.form.get("preferred_store_slug", "").strip() + + errors = [] + if not full_name: + errors.append("Enter your name.") + if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", email): + errors.append("Enter a valid email address.") + else: + clash = User.query.filter(User.email == email, User.id != current_user.id).first() + if clash: + errors.append("That email is already used by another account.") + if store_slug and not any(s.slug == store_slug for s in stores): + errors.append("Choose a store from the list.") + + if errors: + for message in errors: + flash(message, "error") + return render_template("account_edit.html", stores=stores), 400 + + current_user.full_name = full_name + current_user.email = email + current_user.phone = phone + current_user.city = city + current_user.state = state + current_user.preferred_store_slug = store_slug + db.session.commit() + flash("Profile updated.", "success") + return redirect(url_for("account")) + + return render_template("account_edit.html", stores=stores) + + +@app.route("/account/support") +@login_required +def account_support(): + """Support tickets got their own page — they were buried at the bottom of + the account overview with no nav entry, so nothing linked to them.""" + tickets = (SupportTicket.query.filter_by(user_id=current_user.id) + .order_by(SupportTicket.created_at.desc()).all()) + return render_template("account_support.html", tickets=tickets) + + +@app.route("/support/contact", methods=["GET", "POST"]) +@login_required +def support_new_ticket(): + """Open a support request. The SupportTicket table previously had no way + to gain a row from the UI — it was seed-only.""" + if request.method == "POST": + subject = request.form.get("subject", "").strip() + summary = request.form.get("summary", "").strip() + channel = request.form.get("channel", "Chat").strip() or "Chat" + + errors = [] + if len(subject) < 4: + errors.append("Give your request a subject.") + if len(summary) < 10: + errors.append("Describe the issue in a little more detail.") + if channel not in {"Chat", "Email", "Phone"}: + errors.append("Choose a contact method.") + + if errors: + for message in errors: + flash(message, "error") + return render_template("support_contact.html"), 400 + + db.session.add( + SupportTicket( + user_id=current_user.id, + subject=subject[:180], + summary=summary, + channel=channel, + status="Open", + ) + ) + db.session.commit() + flash("Your request was submitted.", "success") + return redirect(url_for("account_support")) + + return render_template("support_contact.html") + + +@app.route("/product//review", methods=["POST"]) +@login_required +def submit_review(sku: str): + """Write a guest review. Server-side validated and persisted, so a task + like 'leave a 4-star review on X' has a real DB after-state to verify.""" + product = Product.query.filter_by(sku=sku).first_or_404() + title = request.form.get("title", "").strip() + body = request.form.get("body", "").strip() + raw_rating = request.form.get("rating", "").strip() + + errors = [] + try: + rating = int(raw_rating) + if not 1 <= rating <= 5: + raise ValueError + except ValueError: + rating = 0 + errors.append("Choose a star rating from 1 to 5.") + if len(title) < 3: + errors.append("Add a short headline for your review.") + if len(body) < 15: + errors.append("Tell us a bit more — reviews need at least 15 characters.") + + if errors: + for message in errors: + flash(message, "error") + return redirect(url_for("product_page", sku=sku)) + + db.session.add( + Review( + product_id=product.id, + author_name=current_user.full_name or "Target guest", + title=title[:140], + body=body, + rating=rating, + verified=False, + ) + ) + db.session.commit() + flash("Thanks — your review was posted.", "success") + return redirect(url_for("product_page", sku=sku)) + + +@app.route("/account/orders") +@login_required +def account_orders(): + orders = Order.query.filter_by(user_id=current_user.id).order_by(Order.placed_at.desc()).all() + return render_template("account_orders.html", orders=orders) + + +@app.route("/account/rewards") +@login_required +def account_rewards(): + activities = RewardActivity.query.filter_by(user_id=current_user.id).order_by(RewardActivity.created_at.desc()).all() + return render_template("account_rewards.html", activities=activities, reward_account=current_user.reward_account) + + +@app.route("/account/wishlist") +@login_required +def account_wishlist(): + wishlist_items = WishlistItem.query.filter_by(user_id=current_user.id).order_by(WishlistItem.created_at.desc()).all() + return render_template("wishlist.html", wishlist_items=wishlist_items) + + +@app.route("/account/wishlist/toggle/", methods=["POST"]) +@login_required +def toggle_wishlist(sku: str): + product = Product.query.filter_by(sku=sku).first_or_404() + item = WishlistItem.query.filter_by(user_id=current_user.id, product_id=product.id).first() + if item: + db.session.delete(item) + flash(f"Removed {product.name} from your wishlist.", "info") + else: + db.session.add(WishlistItem(user_id=current_user.id, product_id=product.id)) + flash(f"Saved {product.name} to your wishlist.", "success") + db.session.commit() + return redirect(request.form.get("next") or request.referrer or url_for("product_page", sku=sku)) + + +@app.route("/compare/toggle/", methods=["POST"]) +def toggle_compare(sku: str): + product = Product.query.filter_by(sku=sku).first_or_404() + if current_user.is_authenticated: + item = CompareItem.query.filter_by(user_id=current_user.id, product_id=product.id).first() + if item: + db.session.delete(item) + flash(f"Removed {product.name} from compare.", "info") + else: + if CompareItem.query.filter_by(user_id=current_user.id).count() >= 4: + oldest = CompareItem.query.filter_by(user_id=current_user.id).order_by(CompareItem.created_at.asc()).first() + if oldest: + db.session.delete(oldest) + db.session.add(CompareItem(user_id=current_user.id, product_id=product.id)) + flash(f"Added {product.name} to compare.", "success") + db.session.commit() + else: + compare_skus = session.get("compare_skus", []) + if sku in compare_skus: + compare_skus = [value for value in compare_skus if value != sku] + flash(f"Removed {product.name} from compare.", "info") + else: + compare_skus = (compare_skus + [sku])[-4:] + flash(f"Added {product.name} to compare.", "success") + session["compare_skus"] = compare_skus + session.modified = True + return redirect(request.form.get("next") or request.referrer or url_for("compare_page")) + + +@app.route("/cart") +def cart_page(): + if not current_user.is_authenticated: + return render_template("cart.html", cart_items=[], totals={"subtotal": 0.0, "count": 0}, requires_login=True) + cart_items = get_cart_items() + totals = cart_totals(cart_items) + return render_template("cart.html", cart_items=cart_items, totals=totals, requires_login=False) + + +@app.route("/cart/add", methods=["POST"]) +@login_required +def add_to_cart(): + product = Product.query.filter_by(sku=request.form.get("sku", "").strip()).first_or_404() + try: + quantity = max(1, min(CART_MAX_QTY, int(request.form.get("quantity", "1") or 1))) + except ValueError: + quantity = 1 + fulfillment_method = request.form.get("fulfillment_method", "delivery") + store_id = request.form.get("store_id") + delivery_option_id = request.form.get("delivery_option_id") + protection_plan_id = request.form.get("protection_plan_id") + + cart_item = CartItem.query.filter_by(user_id=current_user.id, product_id=product.id).first() + if cart_item: + # Adding something already in the cart ACCUMULATES. This used to + # overwrite, so pressing "Add to cart" twice left the quantity at 1 and + # the second click silently did nothing. + cart_item.quantity = min(CART_MAX_QTY, cart_item.quantity + quantity) + else: + cart_item = CartItem(user_id=current_user.id, product_id=product.id) + cart_item.quantity = quantity + db.session.add(cart_item) + cart_item.fulfillment_method = fulfillment_method + cart_item.store_id = int(store_id) if store_id else None + cart_item.delivery_option_id = int(delivery_option_id) if delivery_option_id else None + cart_item.protection_plan_id = int(protection_plan_id) if protection_plan_id else None + db.session.commit() + flash(f"Added {product.name} to your cart.", "success") + return redirect(request.form.get("next") or url_for("cart_page")) + + +@app.route("/cart/update/", methods=["POST"]) +@login_required +def update_cart(item_id: int): + cart_item = CartItem.query.filter_by(id=item_id, user_id=current_user.id).first_or_404() + quantity = int(request.form.get("quantity", cart_item.quantity) or cart_item.quantity) + if quantity <= 0: + db.session.delete(cart_item) + flash(f"Removed {cart_item.product.name} from your cart.", "info") + else: + cart_item.quantity = min(quantity, 5) + db.session.add(cart_item) + flash(f"Updated {cart_item.product.name} in your cart.", "success") + db.session.commit() + return redirect(url_for("cart_page")) + + +@app.route("/cart/remove/", methods=["POST"]) +@login_required +def remove_cart_item(item_id: int): + cart_item = CartItem.query.filter_by(id=item_id, user_id=current_user.id).first_or_404() + product_name = cart_item.product.name + db.session.delete(cart_item) + db.session.commit() + flash(f"Removed {product_name} from your cart.", "info") + return redirect(url_for("cart_page")) + + +@app.route("/checkout") +def checkout(): + if not current_user.is_authenticated: + return render_template("checkout_mode.html", cart_items=[], checkout={}, requires_login=True) + try: + cart_items = require_cart_items() + except RuntimeError: + return redirect(url_for("cart_page")) + return render_template("checkout_mode.html", cart_items=cart_items, checkout=get_checkout_state(), requires_login=False) + + +@app.route("/checkout/shipping", methods=["GET", "POST"]) +@login_required +def checkout_shipping(): + try: + cart_items = require_cart_items() + except RuntimeError: + return redirect(url_for("cart_page")) + + delivery_options = DeliveryOption.query.order_by(DeliveryOption.fee.asc()).all() + checkout = get_checkout_state() + if request.method == "POST": + checkout.update( + { + "mode": "delivery", + "delivery_option_id": request.form.get("delivery_option_id"), + "shipping_name": request.form.get("shipping_name", "").strip(), + "shipping_street": request.form.get("shipping_street", "").strip(), + "shipping_city": request.form.get("shipping_city", "").strip(), + "shipping_state": request.form.get("shipping_state", "").strip(), + "shipping_zip": request.form.get("shipping_zip", "").strip(), + } + ) + save_checkout_state(checkout) + flash("Delivery details saved.", "success") + return redirect(url_for("checkout_payment")) + return render_template( + "checkout_shipping.html", + cart_items=cart_items, + delivery_options=delivery_options, + checkout=checkout, + ) + + +@app.route("/checkout/pickup", methods=["GET", "POST"]) +@login_required +def checkout_pickup(): + try: + cart_items = require_cart_items() + except RuntimeError: + return redirect(url_for("cart_page")) + + stores = Store.query.order_by(Store.city.asc()).all() + checkout = get_checkout_state() + # Pickup windows are the same at every store, so the picker offers the five + # distinct times once and the store is chosen separately. Listing each + # store's rows produced 75 options that all read alike. + windows = pickup_windows() + + if request.method == "POST": + store_id = request.form.get("store_id") + slot_window = request.form.get("slot_window", "").strip() + + errors = [] + store = db.session.get(Store, int(store_id)) if (store_id or "").isdigit() else None + if store is None: + errors.append("Choose a store for pickup.") + if slot_window not in windows: + errors.append("Choose a pickup time.") + + if errors: + for message in errors: + flash(message, "error") + else: + # Resolve the chosen window to that store's own slot row, so the + # order records a slot that genuinely belongs to the pickup store. + slot = resolve_pickup_slot(store.id, slot_window) + checkout.update({"mode": "pickup", "store_id": store_id, + "slot_window": slot_window, + "slot_id": slot.id if slot else None}) + save_checkout_state(checkout) + flash("Store pickup details saved.", "success") + return redirect(url_for("checkout_payment")) + + return render_template( + "checkout_pickup.html", + cart_items=cart_items, + stores=stores, + pickup_windows=windows, + checkout=checkout, + ) + + +@app.route("/checkout/payment", methods=["GET", "POST"]) +@login_required +def checkout_payment(): + try: + cart_items = require_cart_items() + except RuntimeError: + return redirect(url_for("cart_page")) + + checkout = get_checkout_state() + if request.method == "POST": + # Server-side validation: never silently default a missing/!4-digit card. + # (A direct POST used to be accepted because payment_last4 fell back to "1111".) + raw_last4 = (request.form.get("payment_last4") or "").strip() + payment_brand = (request.form.get("payment_brand") or "").strip() + errors = [] + if not raw_last4: + errors.append("Enter the last 4 digits of your demo card.") + elif not re.fullmatch(r"\d{4}", raw_last4): + errors.append("Card digits must be exactly 4 numbers.") + if not payment_brand: + errors.append("Choose a demo card brand.") + if errors: + for message in errors: + flash(message, "error") + return render_template( + "checkout_payment.html", cart_items=cart_items, checkout=checkout, errors=errors + ) + + checkout.update({"payment_brand": payment_brand, "payment_last4": raw_last4}) + save_checkout_state(checkout) + flash("Demo payment details saved.", "success") + return redirect(url_for("checkout_review")) + return render_template("checkout_payment.html", cart_items=cart_items, checkout=checkout) + + +@app.route("/checkout/review", methods=["GET", "POST"]) +@login_required +def checkout_review(): + try: + cart_items = require_cart_items() + except RuntimeError: + return redirect(url_for("cart_page")) + + checkout = get_checkout_state() + summary = build_checkout_summary(cart_items, checkout) + if request.method == "POST": + if summary["mode"] == "delivery" and not checkout.get("delivery_option_id"): + flash("Choose a delivery option before placing the order.", "warning") + return redirect(url_for("checkout_shipping")) + if summary["mode"] == "pickup" and not checkout.get("store_id"): + flash("Choose a pickup store before placing the order.", "warning") + return redirect(url_for("checkout_pickup")) + if not checkout.get("payment_last4"): + flash("Enter demo payment details before placing the order.", "warning") + return redirect(url_for("checkout_payment")) + + order_number = f"TGT-{240000 + Order.query.count() + 1}" + order = Order( + user_id=current_user.id, + order_number=order_number, + email=current_user.email, + status="Ready for pickup" if summary["mode"] == "pickup" else "Preparing shipment", + subtotal=summary["subtotal"], + tax=summary["tax"], + total=summary["total"], + fulfillment_method=summary["mode"], + store_id=summary["store"].id if summary["store"] else None, + delivery_option_id=summary["delivery_option"].id if summary["delivery_option"] else None, + shipping_name=checkout.get("shipping_name", current_user.full_name), + shipping_street=checkout.get("shipping_street", ""), + shipping_city=checkout.get("shipping_city", current_user.city), + shipping_state=checkout.get("shipping_state", current_user.state), + shipping_zip=checkout.get("shipping_zip", "00000"), + payment_brand=checkout.get("payment_brand", "Demo Visa"), + payment_last4=checkout.get("payment_last4", "1111"), + confirmation_note=( + "Synthetic demo order only. No real payment or fulfillment occurred." + ), + pickup_slot_label=summary["pickup_slot"].time_window if summary["pickup_slot"] else "", + ) + db.session.add(order) + db.session.flush() + + for item in cart_items: + db.session.add( + OrderItem( + order_id=order.id, + product_id=item.product_id, + item_name=item.product.name, + quantity=item.quantity, + unit_price=item.product.price, + protection_plan_name=item.protection_plan.name if item.protection_plan else "", + ) + ) + + db.session.add( + PaymentMock( + order_id=order.id, + amount=summary["total"], + card_label=checkout.get("payment_brand", "Demo Visa"), + approval_code=f"TGTOK{order.id:04d}", + ) + ) + + earned_points = int(summary["subtotal"]) + if current_user.reward_account: + current_user.reward_account.points_balance += earned_points + db.session.add( + RewardActivity( + user_id=current_user.id, + points_delta=earned_points, + title=f"Points from order {order.order_number}", + note="Synthetic demo checkout reward credit.", + ) + ) + + for item in cart_items: + db.session.delete(item) + + db.session.commit() + session["target_last_order"] = order.order_number + session["target_lookup_order"] = order.order_number + clear_checkout_state() + flash("Demo checkout completed.", "success") + return redirect(url_for("checkout_confirmation")) + + return render_template("checkout_review.html", cart_items=cart_items, checkout=checkout, summary=summary) + + +@app.route("/checkout/confirmation") +@login_required +def checkout_confirmation(): + order_number = request.args.get("order_number") or session.get("target_last_order") + if not order_number: + flash("There is no recent checkout to confirm.", "info") + return redirect(url_for("account_orders")) + order = Order.query.filter_by(order_number=order_number).first_or_404() + if order.user_id != current_user.id: + abort(403) + return render_template("checkout_confirmation.html", order=order) + + +@app.route("/order-lookup", methods=["GET", "POST"]) +def order_lookup(): + order = None + if request.method == "POST": + order_number = request.form.get("order_number", "").strip().upper() + email = request.form.get("email", "").strip().lower() + order = Order.query.filter_by(order_number=order_number, email=email).first() + if order: + session["target_lookup_order"] = order.order_number + session.modified = True + flash("Demo order located.", "success") + return redirect(url_for("order_detail", order_number=order.order_number)) + flash("We could not match that synthetic order number and email.", "warning") + return render_template("order_lookup.html", order=order) + + +@app.route("/order/") +def order_detail(order_number: str): + order = Order.query.filter_by(order_number=order_number.upper()).first_or_404() + if not order_accessible(order): + flash("Use order lookup or sign in to view that order.", "warning") + return redirect(url_for("order_lookup")) + return render_template("order_detail.html", order=order) + + +@app.route("/_health") +def health(): + return jsonify( + { + "ok": True, + "site": SITE_SLUG, + "products": Product.query.count(), + "stores": Store.query.count(), + "orders": Order.query.count(), + } + ) + + +def initialize_database() -> None: + seed_exists = SEED_DB_PATH.exists() + runtime_exists = RUNTIME_DB_PATH.exists() + + if not runtime_exists and seed_exists: + shutil.copy2(SEED_DB_PATH, RUNTIME_DB_PATH) + runtime_exists = True + + # Avoid touching a freshly copied runtime DB. SQLite file metadata can + # change even on a no-op schema call, which breaks reset md5 identity. + if not runtime_exists: + db.create_all() + + from seed_data import ensure_seed_data + + ensure_seed_data( + force=not seed_exists, + runtime_db_path=RUNTIME_DB_PATH, + seed_db_path=SEED_DB_PATH, + ) + + +with app.app_context(): + initialize_database() + + +if __name__ == "__main__": + port = int(os.environ.get("PORT", SITE_PORT)) + app.run(host="0.0.0.0", port=port, debug=False, use_reloader=False, threaded=True) + diff --git a/sites/target/requirements.txt b/sites/target/requirements.txt new file mode 100644 index 00000000..1d3d2746 --- /dev/null +++ b/sites/target/requirements.txt @@ -0,0 +1,4 @@ +Flask +Flask-Login +Flask-SQLAlchemy + diff --git a/sites/target/seed_data.py b/sites/target/seed_data.py new file mode 100644 index 00000000..2e3dcada --- /dev/null +++ b/sites/target/seed_data.py @@ -0,0 +1,692 @@ +"""Seed the Target mirror from REAL scraped target.com data. + +Everything an agent sees is materialised into instance_seed/target.db at build +time from scraped_data/catalog.json — real product names, real prices, real +guest ratings and real detail-page facts — plus the real target.com product +photography that ships under static/images/products/. + +Determinism (the byte-identical /reset invariant): + * every seed_* function early-returns when the DB is already populated + * every created_at / placed_at is derived from the fixed SEED_TIMESTAMP. + The models default those columns to datetime.utcnow, which would make the + seed unreproducible, so the seed always sets them explicitly. + * no RNG anywhere: all variation is index arithmetic. +""" +from __future__ import annotations + +import json +import shutil +from datetime import datetime, timedelta +from pathlib import Path + +import sys + + +def _app_module(): + """Return the already-loaded app module without re-executing app.py. + + app.py seeds at import time, so a plain `from app import ...` here would be + circular: running `python app.py` loads it as "__main__", and the import + would execute the file a second time under the name "app", re-entering + initialize_database() before this module finished loading. Three entry + points have to work: + site_runner -> `from app import app` (module name "app") + python app.py (module name "__main__") + python seed_data.py (app not loaded yet) + """ + mod = sys.modules.get("app") + if mod is not None: + return mod + main = sys.modules.get("__main__") + if main is not None and hasattr(main, "db") and hasattr(main, "Product"): + return main # we were imported from app.py itself + import app as mod # standalone build: safe to load it + return mod + + +_app = _app_module() + +BENCHMARK_PASSWORD = _app.BENCHMARK_PASSWORD +Brand = _app.Brand +CartItem = _app.CartItem +Category = _app.Category +CompareItem = _app.CompareItem +Deal = _app.Deal +DeliveryOption = _app.DeliveryOption +Order = _app.Order +OrderItem = _app.OrderItem +PaymentMock = _app.PaymentMock +PickupSlot = _app.PickupSlot +Product = _app.Product +ProtectionPlan = _app.ProtectionPlan +Review = _app.Review +RewardAccount = _app.RewardAccount +RewardActivity = _app.RewardActivity +Store = _app.Store +StoreInventory = _app.StoreInventory +SupportArticle = _app.SupportArticle +SupportTicket = _app.SupportTicket +User = _app.User +WishlistItem = _app.WishlistItem +db = _app.db +dump_json = _app.dump_json +slugify = _app.slugify + +BASE_DIR = Path(__file__).resolve().parent +SCRAPED = BASE_DIR / "scraped_data" +CATALOG = SCRAPED / "catalog.json" +SCRAPED_IMAGES = SCRAPED / "images" +PRODUCT_IMAGE_DIR = BASE_DIR / "static" / "images" / "products" + +SEED_TIMESTAMP = datetime(2026, 3, 18, 10, 0, 0) + +# Real Target store locations. +STORES = [ + ("Atlanta Buckhead", "Atlanta", "GA", "3535 Peachtree Rd NE"), + ("Austin Domain", "Austin", "TX", "11500 Rock Rose Ave"), + ("Bellevue Square", "Bellevue", "WA", "103 Bellevue Square"), + ("Boston Fenway", "Boston", "MA", "1341 Boylston St"), + ("Chicago State Street", "Chicago", "IL", "1 S State St"), + ("Denver Stapleton", "Denver", "CO", "7400 E 29th Ave"), + ("Houston Midtown", "Houston", "TX", "3908 Main St"), + ("Los Angeles Westwood", "Los Angeles", "CA", "10861 Weyburn Ave"), + ("Miami Midtown", "Miami", "FL", "3401 N Miami Ave"), + ("Minneapolis Nicollet", "Minneapolis", "MN", "900 Nicollet Mall"), + ("New York Herald Square", "New York", "NY", "112 W 34th St"), + ("Philadelphia Center City", "Philadelphia", "PA", "1900 Chestnut St"), + ("Phoenix Uptown", "Phoenix", "AZ", "100 E Camelback Rd"), + ("Portland Galleria", "Portland", "OR", "939 SW Morrison St"), + ("Seattle Pike Place", "Seattle", "WA", "1401 2nd Ave"), +] + +STORE_SERVICES = [ + ["Order Pickup", "Drive Up", "Starbucks", "CVS pharmacy"], + ["Order Pickup", "Drive Up", "Target Optical", "Wine & Beer"], + ["Order Pickup", "Drive Up", "Starbucks", "Apple shop at Target"], + ["Order Pickup", "Same Day Delivery", "Ulta Beauty at Target", "Starbucks"], +] + +STORE_AMENITIES = [ + ["Curbside pickup", "Order lockers", "Target Circle desk"], + ["Curbside pickup", "Gift wrapping", "Mobile checkout"], + ["Curbside pickup", "Self checkout", "Fitting rooms"], + ["Curbside pickup", "Cafe seating", "Accessible parking"], +] + +STORE_HOURS = ["Mon-Sat 8am-10pm", "Sun 8am-9pm"] + +BENCHMARK_USERS = [ + ("alice.j@test.com", "Alice Johnson", "Seattle", "WA", "seattle-pike-place"), + ("bob.c@test.com", "Bob Chen", "Austin", "TX", "austin-domain"), + ("carol.d@test.com", "Carol Diaz", "Chicago", "IL", "chicago-state-street"), + ("david.k@test.com", "David Kim", "Atlanta", "GA", "atlanta-buckhead"), +] + +# NOTE: there is deliberately no templated review copy here. Reviews come +# only from scraped_data/reviews.json — see seed_reviews() for why. + +SUPPORT_ARTICLES = [ + ("order-pickup-and-drive-up", "Order Pickup and Drive Up", "Pickup & Delivery", + "Order Pickup and Drive Up are free with any order. After you place an order, wait for " + "the 'Ready for pickup' notification, then head to the store. For Drive Up, park in a " + "designated Drive Up space and tap 'I'm on my way' in the app. For demo pickup you should " + "bring a photo ID and the barcode from your order confirmation."), + ("same-day-delivery", "Same Day Delivery", "Pickup & Delivery", + "Same Day Delivery brings eligible items to your door in as little as one hour. Oversized " + "and bulky items may fall back to standard shipping instead of same-day delivery. Target " + "Circle 360 members get unlimited same-day delivery on orders over $35."), + ("returns-and-exchanges", "Returns and Exchanges", "Orders", + "Most items can be returned within 90 days. Target owned brands carry a one-year return " + "window with a receipt. Opened beauty items can be returned within 60 days."), + ("target-circle-rewards", "Target Circle Rewards", "Rewards", + "Target Circle members earn 1% back on every eligible purchase. Your rewards dashboard " + "shows the current points balance, recent earnings and redemptions. The dashboard can also " + "reflect certificate-style savings redemptions applied at checkout."), + ("protection-plans", "Protection Plans", "Orders", + "Protection plans can be added at checkout on eligible items. Two-year plans cover " + "mechanical and electrical failure. Three-year plans additionally include accidental " + "handling coverage such as drops and spills."), + ("price-match-guarantee", "Price Match Guarantee", "Orders", + "If you buy a qualifying item at Target and find it cheaper at a qualifying competitor " + "within 14 days, Target will match the lower price once per item."), + ("registry-and-wish-list", "Registry and Wish List", "Account", + "Create a registry or wish list from any product page. Saved items stay in your account " + "and can be moved into your cart at any time."), + ("payment-options", "Payment Options", "Orders", + "Target accepts major cards, the Target Circle Card and gift cards. Demo checkout in this " + "environment never charges a real card; only the last four digits are recorded."), +] + +DELIVERY_OPTIONS = [ + ("standard-delivery", "Standard delivery", "Free on orders over $35", 0.0, "Arrives in 3-5 days"), + ("express-delivery", "Express delivery", "Faster hand-off from the nearest store", 9.99, "Arrives in 2 days"), + ("same-day-delivery", "Same Day Delivery", "Delivered by a shopper today", 12.99, "Arrives today"), +] + +PICKUP_WINDOWS = [ + ("today-morning", "Today", "10:00 AM - 12:00 PM"), + ("today-afternoon", "Today", "2:00 PM - 4:00 PM"), + ("today-evening", "Today", "6:00 PM - 8:00 PM"), + ("tomorrow-morning", "Tomorrow", "9:00 AM - 11:00 AM"), + ("tomorrow-evening", "Tomorrow", "5:00 PM - 7:00 PM"), +] + +ORDER_STATUSES = ["Delivered", "Shipped", "Ready for pickup", "Processing"] + + +# --------------------------------------------------------------------------- helpers +def _catalog() -> dict: + if not CATALOG.exists(): + raise SystemExit( + f"missing {CATALOG} — run scraped_data/scrape_target.py, scrape_details.py " + "and build_catalog.py first." + ) + return json.loads(CATALOG.read_text()) + + +def _ts(days: int = 0, minutes: int = 0) -> datetime: + """Deterministic timestamp. Never wall-clock — that breaks reproducibility.""" + return SEED_TIMESTAMP - timedelta(days=days, minutes=minutes) + + +def _copy_product_images(products: list[dict]) -> int: + PRODUCT_IMAGE_DIR.mkdir(parents=True, exist_ok=True) + copied = 0 + for item in products: + src_name = item.get("image_file") + if not src_name: + continue + src = SCRAPED_IMAGES / src_name + dest = PRODUCT_IMAGE_DIR / f"{item['sku']}.webp" + if src.exists() and not dest.exists(): + shutil.copy2(src, dest) + copied += 1 + return copied + + +def _counts_ok() -> bool: + return ( + Product.query.count() >= 500 + and Category.query.count() >= 12 + and User.query.count() >= 4 + and Store.query.count() >= 15 + ) + + +# --------------------------------------------------------------------------- seeds +def seed_catalog() -> None: + """Brands, real Target departments, and the real product catalog.""" + if Product.query.count() > 0: + return + data = _catalog() + + brands: dict[str, Brand] = {} + for entry in data["brands"]: + brand = Brand(name=entry["name"], slug=entry["slug"], accent_color="#cc0000") + db.session.add(brand) + brands[entry["name"]] = brand + + categories: dict[str, Category] = {} + for entry in data["categories"]: + category = Category( + name=entry["name"], + slug=entry["slug"], + section=entry["section"], + description=entry["description"], + hero_title=entry["description"], + image_path=f"images/categories/{entry['slug']}.webp", + ) + db.session.add(category) + categories[entry["slug"]] = category + db.session.flush() + + for index, item in enumerate(data["products"]): + category = categories[item["category"]] + brand = brands[item["brand"]] + price = float(item["price"]) + list_price = float(item.get("list_price") or price) + rating = float(item["rating"]) if item.get("rating") else round(4.0 + (index % 9) / 10, 1) + highlights = item.get("highlights") or [] + specs = item.get("specs") or [] + description = item.get("description") or "" + if not description and highlights: + description = highlights[0] + + db.session.add( + Product( + sku=item["sku"], + slug=item["slug"], + name=item["name"], + short_description=description[:200], + long_description=description, + price=price, + list_price=list_price, + rating=rating, + # Real guest-rating count when the PDP exposed one, otherwise a + # deterministic stand-in. + review_count=int(item["rating_count"]) if item.get("rating_count") + else 120 + (index * 7) % 880, + secondary_ratings_json=dump_json(item.get("secondary_ratings") or {}), + percent_recommended=item.get("percent_recommended"), + availability_status="In stock" if index % 17 else "Limited stock", + pickup_eligible=index % 5 != 0, + delivery_eligible=index % 11 != 0, + featured=index % 37 == 0, + deal_badge="Sale" if list_price > price else "", + image_path=f"images/products/{item['sku']}.webp", + highlights_json=dump_json(highlights), + specs_json=dump_json(specs), + tags_json=dump_json([category.name, brand.name, category.section]), + search_keywords=" ".join( + [item["name"], brand.name, category.name, item["category"].replace("-", " ")] + ).lower(), + stock_count=6 + (index * 13) % 90, + category_id=category.id, + brand_id=brand.id, + ) + ) + db.session.flush() + + +def seed_stores() -> None: + if Store.query.count() > 0: + return + for index, (name, city, state, address) in enumerate(STORES): + db.session.add( + Store( + slug=slugify(name), + name=name, + city=city, + state=state, + address=address, + phone=f"(555) 0{index:02d}-{1000 + index * 7}", + hours_json=dump_json(STORE_HOURS), + amenities_json=dump_json(STORE_AMENITIES[index % len(STORE_AMENITIES)]), + services_json=dump_json(STORE_SERVICES[index % len(STORE_SERVICES)]), + hero_copy=f"{name} — shop, pick up and Drive Up in {city}, {state}.", + # No store photography is scraped, and the guide forbids placeholder + # imagery, so store pages simply render without a hero image. + image_path="", + ) + ) + db.session.flush() + + +def seed_fulfillment() -> None: + if DeliveryOption.query.count() > 0: + return + for slug, title, description, fee, eta in DELIVERY_OPTIONS: + db.session.add( + DeliveryOption(slug=slug, title=title, description=description, fee=fee, eta_label=eta) + ) + for store in Store.query.order_by(Store.id).all(): + for index, (code, day, window) in enumerate(PICKUP_WINDOWS): + db.session.add( + PickupSlot( + store_id=store.id, + slot_code=f"{store.slug}-{code}", + day_label=day, + time_window=window, + available_capacity=4 + (store.id + index) % 9, + ) + ) + db.session.flush() + + +def seed_inventory() -> None: + if StoreInventory.query.count() > 0: + return + stores = Store.query.order_by(Store.id).all() + products = Product.query.order_by(Product.id).all() + for p_index, product in enumerate(products): + for offset in range(3): + store = stores[(p_index + offset * 5) % len(stores)] + db.session.add( + StoreInventory( + store_id=store.id, + product_id=product.id, + quantity=4 + (p_index + offset * 3) % 25, + pickup_window="Ready within 2 hours" if offset == 0 else "Ready today", + aisle=f"{chr(65 + (p_index + offset) % 12)}{10 + (p_index % 40)}", + ) + ) + db.session.flush() + + +def seed_reviews() -> None: + """ONLY real scraped guest reviews. Products without them get none. + + There used to be a templated fallback for the ~590 products whose live PDP + exposed no reviews. It produced text that was category-blind: a bag of + ground coffee ended up with "Does what I needed without any fuss. Easy to + clean, too." Wrong-for-the-product copy is worse than an empty section — + it reads as fake to anyone skimming, and tasks that quote reviews would be + grounded in invented text. + + A product can legitimately show a star-rating count with no written + reviews; that is how target.com behaves too ("ratings" != "reviews"). + """ + if Review.query.count() > 0: + return + by_sku = {item["sku"]: item for item in _catalog()["products"]} + + for index, product in enumerate(Product.query.order_by(Product.id).all()): + for slot, r in enumerate((by_sku.get(product.sku) or {}).get("reviews") or []): + rating = r.get("rating") + db.session.add( + Review( + product_id=product.id, + author_name=r["author"] or "Target guest", + title=r["title"] or "Guest review", + body=r["body"], + rating=min(5, max(1, int(round(float(rating))))) if rating + else min(5, max(3, int(round(product.rating)))), + verified=r["verified"], + created_at=_ts(days=(index * 7 + slot * 11) % 240), + ) + ) + db.session.flush() + + +def seed_protection_plans() -> None: + if ProtectionPlan.query.count() > 0: + return + for product in Product.query.filter(Product.price >= 60).order_by(Product.id).all(): + db.session.add( + ProtectionPlan( + product_id=product.id, + name="2-Year Protection Plan", + years=2, + price=round(max(3.0, product.price * 0.08), 2), + coverage_summary="Covers mechanical and electrical failure once the " + "manufacturer warranty ends.", + accidental=False, + priority_support=False, + ) + ) + db.session.add( + ProtectionPlan( + product_id=product.id, + name="3-Year Protection Plan", + years=3, + price=round(max(5.0, product.price * 0.13), 2), + coverage_summary="Everything in the 2-year plan plus accidental handling " + "coverage for drops, spills and cracked screens.", + accidental=True, + priority_support=True, + ) + ) + db.session.flush() + + +def seed_deals() -> None: + if Deal.query.count() > 0: + return + # Products are inserted grouped by category, so taking the first N by id + # would fill the deals page with one department. Round-robin across + # categories instead — deterministic, and it mirrors how target.com spreads + # its weekly deals over the whole store. + discounted = Product.query.filter(Product.list_price > Product.price).order_by(Product.id).all() + by_category: dict[int, list] = {} + for product in discounted: + by_category.setdefault(product.category_id, []).append(product) + + ordered: list = [] + for rank in range(max((len(v) for v in by_category.values()), default=0)): + for category_id in sorted(by_category): + bucket = by_category[category_id] + if rank < len(bucket): + ordered.append(bucket[rank]) + if len(ordered) >= 48: + break + + for index, product in enumerate(ordered[:48]): + pct = int(round((product.list_price - product.price) / product.list_price * 100)) + db.session.add( + Deal( + slug=f"deal-{product.sku.lower()}", + title=product.name, + subtitle=f"Save {pct}% on this {product.category.name.lower()} pick.", + badge="Member Deal" if index % 3 else "Top Deal", + discount_percent=pct, + ends_label=f"Ends Mar {24 + index % 7}", + category_slug=product.category.slug, + product_id=product.id, + ) + ) + db.session.flush() + + +def seed_support() -> None: + if SupportArticle.query.count() > 0: + return + for slug, title, topic, body in SUPPORT_ARTICLES: + db.session.add( + SupportArticle( + slug=slug, + title=title, + topic=topic, + summary=body.split(". ")[0] + ".", + body=body, + upstream_url=f"https://help.target.com/help/subcategoryarticle?childcat={slug}", + keywords_json=dump_json(title.lower().split()), + ) + ) + db.session.flush() + + +def seed_users() -> None: + if User.query.filter_by(email="alice.j@test.com").first(): + return + for index, (email, name, city, state, store_slug) in enumerate(BENCHMARK_USERS): + user = User( + email=email, + full_name=name, + phone=f"(555) 1{index:02d}-2{index}00", + city=city, + state=state, + preferred_store_slug=store_slug, + member_tier="Target Circle 360" if index % 2 == 0 else "Target Circle", + rewards_member_id=f"TC-{4400 + index * 37}", + created_at=_ts(days=420 - index * 30), + ) + user.set_password(BENCHMARK_PASSWORD) + db.session.add(user) + db.session.flush() + + for index, user in enumerate(User.query.order_by(User.id).all()): + db.session.add( + RewardAccount( + user_id=user.id, + member_id=user.rewards_member_id, + points_balance=1240 + index * 315, + tier=user.member_tier, + available_certificates=index % 3, + ) + ) + db.session.flush() + + +def seed_reward_activity() -> None: + if RewardActivity.query.count() > 0: + return + entries = [ + ("Earned on purchase", 120), ("Earned on purchase", 85), + ("Certificate redeemed", -250), ("Target Circle Week bonus", 300), + ("Earned on purchase", 64), + ] + for u_index, user in enumerate(User.query.order_by(User.id).all()): + for a_index, (title, delta) in enumerate(entries): + db.session.add( + RewardActivity( + user_id=user.id, + points_delta=delta + u_index * 5, + title=title, + note="Applied to your Target Circle dashboard.", + created_at=_ts(days=a_index * 21 + u_index), + ) + ) + db.session.flush() + + +def seed_account_state() -> None: + if CartItem.query.count() > 0 or WishlistItem.query.count() > 0: + return + users = User.query.order_by(User.id).all() + products = Product.query.order_by(Product.id).all() + stores = Store.query.order_by(Store.id).all() + for u_index, user in enumerate(users): + for slot in range(2): + product = products[(u_index * 97 + slot * 41) % len(products)] + db.session.add( + CartItem( + user_id=user.id, + product_id=product.id, + quantity=1 + slot, + fulfillment_method="delivery" if slot % 2 == 0 else "pickup", + store_id=stores[(u_index + slot) % len(stores)].id, + created_at=_ts(days=2, minutes=slot * 30), + ) + ) + for slot in range(4): + product = products[(u_index * 53 + slot * 29) % len(products)] + db.session.add( + WishlistItem(user_id=user.id, product_id=product.id, created_at=_ts(days=9 + slot)) + ) + for slot in range(3): + product = products[(u_index * 71 + slot * 17) % len(products)] + db.session.add( + CompareItem(user_id=user.id, product_id=product.id, created_at=_ts(days=4 + slot)) + ) + db.session.flush() + + +def seed_orders() -> None: + if Order.query.count() > 0: + return + users = User.query.order_by(User.id).all() + products = Product.query.order_by(Product.id).all() + stores = Store.query.order_by(Store.id).all() + options = {o.slug: o for o in DeliveryOption.query.all()} + counter = 0 + for u_index, user in enumerate(users): + for o_index in range(4): + counter += 1 + pickup = o_index % 2 == 1 + store = stores[(u_index + o_index) % len(stores)] + picked = [products[(counter * 131 + s * 37) % len(products)] + for s in range(1 + o_index % 2)] + subtotal = round(sum(p.price for p in picked), 2) + tax = round(subtotal * 0.0875, 2) + fee = 0.0 if pickup else options["standard-delivery"].fee + order = Order( + user_id=user.id, + order_number=f"TGT-{240000 + counter}", + email=user.email, + status=ORDER_STATUSES[(u_index + o_index) % len(ORDER_STATUSES)], + subtotal=subtotal, + tax=tax, + total=round(subtotal + tax + fee, 2), + fulfillment_method="pickup" if pickup else "delivery", + store_id=store.id if pickup else None, + delivery_option_id=None if pickup else options["standard-delivery"].id, + shipping_name=user.full_name, + shipping_city=user.city, + shipping_state=user.state, + shipping_zip=f"9{8000 + u_index * 11}", + payment_brand="Target Circle Card" if u_index % 2 == 0 else "Demo Visa", + payment_last4=f"{4000 + counter}"[-4:], + confirmation_note="Synthetic demo order — no real payment was processed.", + pickup_slot_label=f"{store.name} · Today 2:00 PM - 4:00 PM" if pickup else "", + placed_at=_ts(days=6 * counter), + ) + db.session.add(order) + db.session.flush() + for product in picked: + db.session.add( + OrderItem( + order_id=order.id, + product_id=product.id, + item_name=product.name, + quantity=1, + unit_price=product.price, + protection_plan_name="", + ) + ) + db.session.add( + PaymentMock( + order_id=order.id, + amount=order.total, + card_label=f"{order.payment_brand} ending in {order.payment_last4}", + auth_status="Approved", + approval_code=f"AP{700000 + counter * 13}", + created_at=order.placed_at, + ) + ) + db.session.flush() + + +def seed_support_tickets() -> None: + if SupportTicket.query.count() > 0: + return + subjects = [ + ("Where is my Drive Up order?", "Order Pickup"), + ("Return window question", "Returns"), + ] + for u_index, user in enumerate(User.query.order_by(User.id).all()): + subject, topic = subjects[u_index % len(subjects)] + db.session.add( + SupportTicket( + user_id=user.id, + subject=subject, + status="Open" if u_index % 2 == 0 else "Resolved", + channel="Demo contact form", + summary=f"{topic} — submitted from the demo support form.", + created_at=_ts(days=12 + u_index * 3), + ) + ) + db.session.flush() + + +# --------------------------------------------------------------------------- entry point +def ensure_seed_data(force: bool, runtime_db_path: Path, seed_db_path: Path) -> None: + if not (force or not _counts_ok()): + return + data = _catalog() + copied = _copy_product_images(data["products"]) + db.drop_all() + db.create_all() + seed_catalog() + seed_stores() + seed_fulfillment() + seed_inventory() + seed_reviews() + seed_protection_plans() + seed_deals() + seed_support() + seed_users() + seed_reward_activity() + seed_account_state() + seed_orders() + seed_support_tickets() + db.session.commit() + print( + f"seeded {Product.query.count()} products / {Category.query.count()} categories / " + f"{Brand.query.count()} brands / {Review.query.count()} reviews " + f"({copied} product images copied)" + ) + db.session.remove() + if runtime_db_path.exists(): + seed_db_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(runtime_db_path, seed_db_path) + + +if __name__ == "__main__": + from app import app + + with app.app_context(): + ensure_seed_data( + force=True, + runtime_db_path=BASE_DIR / "instance" / "target.db", + seed_db_path=BASE_DIR / "instance_seed" / "target.db", + ) diff --git a/sites/target/static/css/main.css b/sites/target/static/css/main.css new file mode 100644 index 00000000..7321e817 --- /dev/null +++ b/sites/target/static/css/main.css @@ -0,0 +1,1314 @@ +:root { + --tgt-blue: #cc0000; + --tgt-blue-deep: #8f0000; + --tgt-blue-soft: #fff0f0; + --tgt-yellow: #ffffff; + --tgt-ink: #1f1717; + --tgt-slate: #6f5f5f; + --tgt-border: #efd9d9; + --tgt-surface: #ffffff; + --tgt-surface-alt: #fff8f8; + --tgt-shadow: 0 20px 50px rgba(89, 12, 12, 0.08); + --tgt-radius: 22px; + --tgt-shell: min(1240px, calc(100vw - 32px)); +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + font-family: "Segoe UI", "Trebuchet MS", Arial, sans-serif; + color: var(--tgt-ink); + background: + radial-gradient(circle at top right, rgba(204, 0, 0, 0.08), transparent 28%), + linear-gradient(180deg, #fffafa 0%, #fff2f2 100%); +} + +a { + color: var(--tgt-blue); + text-decoration: none; +} + +img { + max-width: 100%; + display: block; +} + +button, +input, +select { + font: inherit; +} + +.shell { + width: var(--tgt-shell); + margin: 0 auto; +} + +.promo-strip { + background: linear-gradient(90deg, var(--tgt-blue-deep), var(--tgt-blue)); + color: #fff; + font-size: 0.92rem; +} + +.promo-strip__inner { + display: flex; + gap: 20px; + align-items: center; + justify-content: space-between; + padding: 10px 0; + flex-wrap: wrap; +} + +.site-header { + position: sticky; + top: 0; + z-index: 20; + backdrop-filter: blur(14px); + background: rgba(255, 255, 255, 0.92); + border-bottom: 1px solid rgba(204, 0, 0, 0.08); +} + +.site-header__top, +.site-header__nav { + display: flex; + align-items: center; + gap: 18px; + padding: 14px 0; +} + +.site-header__nav { + padding-top: 0; + padding-bottom: 14px; +} + +.logo-badge { + flex: 0 0 auto; + width: 118px; + height: 86px; + border-radius: 18px; + background: linear-gradient(145deg, #ffffff, #fff4f4); + color: #1a1010; + display: grid; + place-items: center; + box-shadow: var(--tgt-shadow); + font-weight: 900; + line-height: 1; + border: 2px solid rgba(204, 0, 0, 0.14); + position: relative; +} + +.logo-badge__bullseye { + width: 46px; + height: 46px; + border-radius: 999px; + background: + radial-gradient(circle, #fff 0 5px, #cc0000 5px 12px, #fff 12px 18px, #cc0000 18px 100%); +} + +.logo-badge__text { + font-size: 0.74rem; + letter-spacing: 0.08em; + text-align: center; + text-transform: uppercase; +} + +.site-search { + flex: 1 1 auto; + display: grid; + grid-template-columns: 1fr auto; + gap: 12px; +} + +.site-search input, +.inline-search input, +.auth-form input, +.auth-form select, +.filter-form input, +.filter-form select { + width: 100%; + border: 1px solid var(--tgt-border); + border-radius: 14px; + padding: 13px 15px; + background: #fff; +} + +.site-search button, +.button { + /* inline-flex is required: is inline by default, so its + vertical padding would render outside the line box and overlap the content + above it (this caused the deal-card button to sit on top of the "% off" row). */ + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 14px; + background: var(--tgt-yellow); + color: #1a1010; + font-weight: 800; + padding: 13px 18px; + cursor: pointer; + transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease; +} + +.site-search button:hover, +.button:hover { + transform: translateY(-1px); + box-shadow: 0 12px 24px rgba(9, 17, 31, 0.12); +} + +.button--secondary { + background: var(--tgt-blue); + color: #fff; +} + +.button--ghost { + background: transparent; + border: 1px solid var(--tgt-border); + color: var(--tgt-blue); +} + +.header-actions { + display: flex; + align-items: center; + gap: 14px; + font-weight: 700; + flex-wrap: wrap; +} + +.header-actions span { + display: inline-grid; + place-items: center; + min-width: 26px; + height: 26px; + margin-left: 8px; + border-radius: 999px; + background: var(--tgt-blue-soft); + color: var(--tgt-blue); +} + +.category-nav { + display: flex; + gap: 16px; + flex-wrap: wrap; +} + +.category-nav a { + color: var(--tgt-ink); + font-weight: 700; +} + +.store-banner { + background: linear-gradient(90deg, rgba(0, 70, 190, 0.06), rgba(255, 242, 0, 0.25)); + border-bottom: 1px solid rgba(0, 70, 190, 0.08); +} + +.store-banner__inner { + display: flex; + gap: 12px; + align-items: center; + padding: 12px 0; + flex-wrap: wrap; +} + +.page-main { + padding-bottom: 64px; +} + +.page-section { + padding: 28px 0; +} + +.flash-stack { + display: grid; + gap: 10px; + margin-top: 18px; +} + +.flash { + padding: 14px 16px; + border-radius: 16px; + border: 1px solid var(--tgt-border); + background: #fff; +} + +.flash--success { + background: #edf9f0; + border-color: #b9e2c5; +} + +.flash--warning { + background: #fff7e8; + border-color: #f1ddb0; +} + +.flash--danger { + background: #fff0f0; + border-color: #efb6b6; +} + +.flash--info { + background: #fdeeee; + border-color: #f3c9c9; +} + +.hero { + padding: 34px 0 14px; +} + +.hero__grid { + display: grid; + grid-template-columns: 1.15fr 0.85fr; + gap: 28px; + align-items: stretch; +} + +.hero__copy, +.hero__visual { + border-radius: 30px; + box-shadow: var(--tgt-shadow); +} + +.hero__copy { + padding: 42px; + background: + radial-gradient(circle at top left, rgba(255, 242, 0, 0.26), transparent 34%), + linear-gradient(135deg, var(--tgt-blue-deep), var(--tgt-blue)); + color: #fff; +} + +.hero__copy h1 { + margin: 10px 0 16px; + font-size: clamp(2rem, 4vw, 3.8rem); + line-height: 1.02; +} + +.hero__copy p { + max-width: 54ch; + color: rgba(255, 255, 255, 0.9); +} + +.hero__actions, +.inline-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.hero__actions { + margin-top: 26px; +} + +.hero__visual { + background: linear-gradient(180deg, #fdeeee, #ffffff); + padding: 24px; + display: grid; + place-items: center; +} + +.rating-breakdown { + margin-bottom: 22px; + padding-bottom: 18px; + border-bottom: 1px solid var(--tgt-border); +} + +.rating-breakdown__recommend { + margin: 0 0 12px; + font-size: 1rem; +} + +.rating-breakdown__recommend strong { + color: var(--tgt-blue); + font-size: 1.2rem; +} + +.rating-breakdown__row { + display: grid; + grid-template-columns: 140px 1fr auto; + align-items: center; + gap: 12px; + padding: 4px 0; + font-size: 0.9rem; +} + +.rating-breakdown__label { + text-transform: capitalize; + color: var(--tgt-slate); +} + +.rating-breakdown__bar { + display: block; + height: 8px; + border-radius: 999px; + background: var(--tgt-border); + overflow: hidden; +} + +.rating-breakdown__bar > span { + display: block; + height: 100%; + background: var(--tgt-blue); +} + +.category-section { + margin-bottom: 34px; +} + +.category-section__heading { + margin: 0 0 14px; + font-size: 1.15rem; + padding-bottom: 8px; + border-bottom: 2px solid var(--tgt-border); +} + +/* Two-level category flyout (target.com's "Categories" mega-menu). + Click-to-open via
; see base.html for why not :hover. */ +.category-nav__root { + position: relative; + display: inline-block; +} + +.category-nav__all { + cursor: pointer; + list-style: none; +} + +.category-nav__all::-webkit-details-marker { + display: none; +} + +.category-nav__root[open] .category-nav__all { + color: var(--tgt-blue); +} + +.mega-menu__all-link { + grid-column: 1 / -1; + padding-top: 14px; + border-top: 1px solid var(--tgt-border); + font-weight: 700; +} + +.mega-menu { + display: none; + position: absolute; + top: 100%; + left: 0; + z-index: 40; + min-width: 720px; + padding: 22px 26px; + gap: 22px 34px; + grid-template-columns: repeat(3, minmax(160px, 1fr)); + background: #fff; + border: 1px solid var(--tgt-border); + border-radius: 16px; + box-shadow: var(--tgt-shadow); +} + +.category-nav__root:hover .mega-menu, +.category-nav__root:focus-within .mega-menu { + display: grid; +} + +.mega-menu__heading { + margin: 0 0 8px; + font-size: 0.82rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--tgt-slate); +} + +.mega-menu__section a { + display: block; + padding: 5px 0; + color: var(--tgt-ink); + font-size: 0.95rem; +} + +.mega-menu__section a:hover { + color: var(--tgt-blue); + text-decoration: underline; +} + +.confirmation-details { + display: grid; + grid-template-columns: auto 1fr; + gap: 6px 18px; + max-width: 560px; + margin: 20px auto 0; + text-align: left; +} + +.confirmation-details dt { + color: var(--tgt-slate); + font-size: 0.9rem; +} + +.confirmation-details dd { + margin: 0; + font-weight: 600; +} + +.checkout-step--link { + cursor: pointer; +} + +.checkout-step--link:hover strong { + color: var(--tgt-blue); + text-decoration: underline; +} + +.deal-card__media { + width: 100%; + aspect-ratio: 4 / 3; + display: grid; + place-items: center; + background: #fff; + border: 1px solid var(--tgt-border); + border-radius: 14px; + padding: 10px; +} + +.deal-card__media img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.deal-card__price { + margin: 0; + display: flex; + align-items: baseline; + gap: 8px; +} + +.deal-card__price strong { + font-size: 1.25rem; + color: var(--tgt-blue); +} + +.deal-card__price s { + color: var(--tgt-slate); + font-size: 0.9rem; +} + +.listing-count { + margin: 0 0 16px; + color: var(--tgt-slate); + font-size: 0.92rem; +} + +.pager { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 28px; +} + +.pager__page, +.pager__step { + border: 1px solid var(--tgt-border); + border-radius: 999px; + padding: 8px 16px; + color: var(--tgt-blue); + background: #fff; + font-weight: 600; +} + +.pager__page--current { + background: var(--tgt-blue); + border-color: var(--tgt-blue); + color: #fff; +} + +.pager__step--off { + color: var(--tgt-slate); + opacity: 0.55; +} + +.pager__gap { + color: var(--tgt-slate); + padding: 0 4px; +} + +/* Four real catalog photos instead of a placeholder hero graphic. */ +.hero__collage { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + width: 100%; +} + +.hero__collage a { + background: #fff; + border: 1px solid var(--tgt-border); + border-radius: 16px; + padding: 12px; + display: grid; + place-items: center; + aspect-ratio: 1 / 1; +} + +.hero__collage img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.eyebrow { + margin: 0; + text-transform: uppercase; + letter-spacing: 0.14em; + font-size: 0.76rem; + font-weight: 800; + color: var(--tgt-blue); +} + +.feature-strip, +.deal-grid, +.category-grid, +.store-grid, +.support-grid, +.plan-grid, +.stats-grid { + display: grid; + gap: 18px; +} + +.feature-strip { + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-top: 10px; +} + +.feature-tile, +.deal-card, +.category-card, +.store-card, +.support-card, +.panel, +.stat-card, +.product-card, +.plan-card, +.ticket-card, +.review-card { + background: var(--tgt-surface); + border: 1px solid rgba(0, 70, 190, 0.08); + border-radius: var(--tgt-radius); + box-shadow: var(--tgt-shadow); +} + +.feature-tile { + display: grid; + grid-template-columns: 120px 1fr; + gap: 14px; + padding: 14px; + align-items: center; +} + +.feature-tile strong, +.section-heading h1, +.section-heading h2, +.product-card h3, +.panel h1, +.panel h2, +.panel h3 { + margin: 0; +} + +.feature-tile span, +.category-card span, +.store-card span, +.support-card span, +.deal-card__badge, +.product-card__meta, +.tag-row span, +.tag-row li { + font-size: 0.82rem; + color: var(--tgt-slate); +} + +.section-heading { + display: flex; + gap: 16px; + justify-content: space-between; + align-items: end; + margin-bottom: 18px; + flex-wrap: wrap; +} + +.section-heading h1 { + font-size: clamp(1.8rem, 3vw, 2.8rem); +} + +.section-heading h2 { + font-size: clamp(1.35rem, 2.3vw, 2rem); +} + +.result-count { + padding: 10px 14px; + border-radius: 999px; + background: var(--tgt-blue-soft); + color: var(--tgt-blue); + font-weight: 800; +} + +.deal-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.deal-card, +.support-card, +.plan-card, +.panel, +.ticket-card, +.review-card, +.stat-card { + padding: 22px; +} + +.deal-card__badge { + display: inline-block; + padding: 7px 10px; + border-radius: 999px; + background: rgba(255, 242, 0, 0.28); + color: #4e4700; + font-weight: 800; +} + +.deal-card__meta, +.summary-row, +.reward-row, +.order-row, +.inventory-row, +.rating-row { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; +} + +.product-grid { + display: grid; + gap: 18px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.product-card { + display: grid; + grid-template-rows: auto 1fr auto; + overflow: hidden; +} + +.product-card__image-wrap { + position: relative; + background: linear-gradient(180deg, #fff5f5, #fff); + padding: 18px; +} + +.product-card__image { + height: 220px; + width: 100%; + object-fit: cover; + border-radius: 18px; +} + +.product-card__badge { + position: absolute; + top: 16px; + left: 16px; + padding: 8px 12px; + border-radius: 999px; + background: var(--tgt-yellow); + color: #1b1600; + font-weight: 800; +} + +.product-card__body, +.product-card__actions { + padding: 20px; +} + +.product-card__desc, +.product-summary, +.plan-card p, +.support-card p, +.deal-card p, +.ticket-card p { + color: var(--tgt-slate); + line-height: 1.5; +} + +.price-row { + display: flex; + gap: 10px; + align-items: baseline; + flex-wrap: wrap; +} + +.price-row strong { + font-size: 1.4rem; +} + +.price-row--large strong { + font-size: 2rem; +} + +.price-row__strike { + color: #a38f8f; + text-decoration: line-through; +} + +.price-row__save { + color: #0b8f4d; + font-weight: 700; +} + +.tag-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 0; + margin: 14px 0 0; + list-style: none; +} + +.tag-row span, +.tag-row li { + padding: 6px 10px; + border-radius: 999px; + background: var(--tgt-surface-alt); + border: 1px solid var(--tgt-border); +} + +.tag-row--left { + justify-content: flex-start; +} + +.catalog-layout, +.dual-grid, +.checkout-layout, +.article-layout, +.auth-layout, +.account-layout, +.product-layout { + display: grid; + gap: 22px; +} + +.catalog-layout { + grid-template-columns: 280px 1fr; +} + +.filter-card, +.order-summary, +.account-nav { + position: sticky; + top: 145px; + align-self: start; +} + +.filter-card, +.order-summary, +.account-nav { + background: #fff; + border: 1px solid rgba(0, 70, 190, 0.08); + border-radius: var(--tgt-radius); + box-shadow: var(--tgt-shadow); +} + +.filter-card { + padding: 18px; +} + +.filter-form { + display: grid; + gap: 14px; +} + +.filter-form__row, +.auth-form .filter-form__row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.checkbox-row { + display: flex; + align-items: center; + gap: 10px; +} + +.checkbox-row input { + width: auto; +} + +.product-layout { + grid-template-columns: 1.1fr 1fr 320px; + align-items: start; +} + +.product-layout__media, +.product-layout__content, +.product-layout__buybox { + background: #fff; + border: 1px solid rgba(0, 70, 190, 0.08); + border-radius: var(--tgt-radius); + box-shadow: var(--tgt-shadow); +} + +.product-layout__media { + padding: 24px; + background: linear-gradient(180deg, #fff5f5, #fff); +} + +.product-layout__content, +.product-layout__buybox { + padding: 24px; +} + +.bullet-list { + display: grid; + gap: 10px; + padding-left: 20px; +} + +.bullet-list--compact { + gap: 8px; +} + +.buybox-price { + font-size: 2rem; + font-weight: 900; + margin: 8px 0 14px; +} + +.buybox-panel { + display: grid; + gap: 10px; + padding: 14px; + border-radius: 18px; + background: var(--tgt-surface-alt); +} + +.spec-section + .spec-section { + margin-top: 18px; +} + +.spec-grid { + display: grid; + grid-template-columns: 180px 1fr; + gap: 12px 18px; +} + +.spec-grid dt { + font-weight: 700; + color: var(--tgt-slate); +} + +.compare-strip { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 18px; + margin-bottom: 20px; +} + +.compare-product { + padding: 18px; + border-radius: var(--tgt-radius); + background: #fff; + border: 1px solid rgba(0, 70, 190, 0.08); + box-shadow: var(--tgt-shadow); +} + +.table-wrap { + overflow-x: auto; + border-radius: 18px; +} + +.compare-table, +.inventory-table { + width: 100%; + border-collapse: collapse; + background: #fff; + overflow: hidden; +} + +.compare-table th, +.compare-table td, +.inventory-table th, +.inventory-table td { + padding: 14px 16px; + border-bottom: 1px solid var(--tgt-border); + text-align: left; + vertical-align: top; +} + +.store-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.store-card, +.category-card { + display: grid; + grid-template-columns: 160px 1fr; + gap: 16px; + padding: 16px; + align-items: center; +} + +.store-list, +.support-list { + display: grid; + gap: 14px; +} + +.store-list__item, +.support-list__item, +.mini-product, +.order-row, +.wishlist-row, +.cart-row { + display: flex; + gap: 14px; + align-items: center; +} + +/* Constrain the cart thumbnail: without this the full-size product image takes + the whole row and overlaps the name / qty select / update button. */ +.cart-row img { + width: 96px; + height: 96px; + flex: 0 0 96px; + object-fit: contain; + border-radius: 12px; + background: #fff; +} + +.cart-row > a, +.cart-row__info { + min-width: 0; /* let long product names wrap instead of pushing siblings */ +} + +.cart-row__actions { + margin-left: auto; + flex-shrink: 0; +} + +/* Deal cards: explicit column flow so the badge/title/meta/button can never collide. */ +.deal-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; +} + +.store-list__item, +.support-list__item, +.mini-product, +.ticket-card, +.review-card, +.order-row { + padding: 14px; + border-radius: 18px; + background: var(--tgt-surface-alt); +} + +.support-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.support-card strong { + display: block; + margin: 8px 0; +} + +.topic-row { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 18px; +} + +.topic-row a { + padding: 9px 12px; + border-radius: 999px; + background: #fff; + border: 1px solid var(--tgt-border); + font-weight: 700; + color: var(--tgt-slate); +} + +.topic-row a.is-active { + background: var(--tgt-blue); + color: #fff; + border-color: var(--tgt-blue); +} + +.search-sections { + display: grid; + gap: 20px; +} + +.auth-layout { + grid-template-columns: 1fr 0.9fr; +} + +.auth-form { + display: grid; + gap: 14px; +} + +.auth-form label, +.filter-form label { + display: grid; + gap: 8px; + font-weight: 700; +} + +.auth-note { + color: var(--tgt-slate); +} + +.account-layout { + grid-template-columns: 240px 1fr; +} + +.account-nav { + display: grid; + gap: 8px; + padding: 16px; +} + +.account-nav a { + padding: 12px 14px; + border-radius: 14px; + font-weight: 700; + color: var(--tgt-ink); +} + +.account-nav a.is-active { + background: var(--tgt-blue); + color: #fff; +} + +.account-content { + display: grid; + gap: 20px; +} + +.stats-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.stat-card span { + display: block; + color: var(--tgt-slate); +} + +.stat-card strong { + display: block; + margin-top: 8px; + font-size: 2rem; +} + +.reward-row, +.cart-row, +.wishlist-row { + padding: 14px 0; + border-bottom: 1px solid var(--tgt-border); +} + +.reward-points { + font-size: 1.1rem; + font-weight: 900; + color: #0b8f4d; +} + +.reward-points.is-negative { + color: #b53f46; +} + +.checkout-layout { + grid-template-columns: minmax(0, 1fr) 320px; + align-items: start; +} + +.checkout-steps { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 20px; +} + +.checkout-step { + padding: 14px; + border-radius: 18px; + background: #fff; + border: 1px solid var(--tgt-border); + display: flex; + gap: 10px; + align-items: center; + color: var(--tgt-slate); +} + +.checkout-step span { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border-radius: 999px; + background: var(--tgt-surface-alt); +} + +.checkout-step.is-active { + background: linear-gradient(135deg, rgba(0, 70, 190, 0.09), rgba(255, 242, 0, 0.18)); + color: var(--tgt-ink); + border-color: rgba(0, 70, 190, 0.18); +} + +.summary-row { + padding: 12px 0; + border-bottom: 1px solid var(--tgt-border); +} + +.summary-row--total { + font-size: 1.08rem; + font-weight: 900; +} + +.empty-state { + padding: 48px 28px; + text-align: center; + border-radius: 28px; + background: #fff; + border: 1px solid rgba(0, 70, 190, 0.08); + box-shadow: var(--tgt-shadow); +} + +.empty-state--success { + background: linear-gradient(180deg, #ffffff, #fff5f5); +} + +.inline-form { + display: flex; + gap: 12px; + align-items: end; + flex-wrap: wrap; +} + +.inline-form label { + display: grid; + gap: 6px; + font-weight: 700; +} + +.inline-form select { + min-width: 88px; +} + +.panel--store-hero { + padding: 20px; + background: linear-gradient(180deg, #fff5f5, #fff); +} + +.site-footer { + margin-top: 60px; + padding: 34px 0 40px; + background: #221515; + color: rgba(255, 255, 255, 0.88); +} + +.site-footer__grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 20px; +} + +.site-footer h4 { + margin-top: 0; + color: #fff; +} + +.site-footer ul { + margin: 0; + padding-left: 18px; +} + +@media (max-width: 1100px) { + .hero__grid, + .catalog-layout, + .product-layout, + .checkout-layout, + .account-layout, + .auth-layout { + grid-template-columns: 1fr; + } + + .filter-card, + .order-summary, + .account-nav { + position: static; + } + + .product-grid, + .deal-grid, + .store-grid, + .support-grid, + .feature-strip, + .stats-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .site-header__top { + flex-wrap: wrap; + } +} + +@media (max-width: 760px) { + :root { + --tgt-shell: min(100vw - 18px, 100vw - 18px); + } + + .product-grid, + .deal-grid, + .store-grid, + .support-grid, + .feature-strip, + .stats-grid, + .site-footer__grid, + .filter-form__row, + .auth-form .filter-form__row, + .store-card, + .category-card { + grid-template-columns: 1fr; + } + + .checkout-steps { + grid-template-columns: 1fr; + } + + .site-search { + grid-template-columns: 1fr; + } + + .header-actions { + width: 100%; + justify-content: space-between; + } + + .feature-tile { + grid-template-columns: 1fr; + } + + .spec-grid { + grid-template-columns: 1fr; + } +} + diff --git a/sites/target/static/js/main.js b/sites/target/static/js/main.js new file mode 100644 index 00000000..73743768 --- /dev/null +++ b/sites/target/static/js/main.js @@ -0,0 +1,2 @@ +document.documentElement.classList.add("js"); + diff --git a/sites/target/tasks.jsonl b/sites/target/tasks.jsonl new file mode 100644 index 00000000..84284b55 --- /dev/null +++ b/sites/target/tasks.jsonl @@ -0,0 +1,20 @@ +{"web_name": "Target", "id": "Target--0", "ques": "Find the Help topic that covers returns and exchanges. How long do you have to return an opened beauty item, and what is the return window for Target owned brands?", "web": "http://localhost:40016/", "upstream_url": "https://www.target.com/", "verifier_path": "sites/target/verify/verify_0.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent's trajectory MUST contain a navigation to the returns and exchanges help article on the site. (2) The answer MUST state the return window for OPENED BEAUTY items and the window for TARGET OWNED BRANDS, both of which appear only in the article body. (3) An answer that only repeats the general return window shown in the search-result summary is a FAIL, because that figure is readable without opening the article. FAIL if: no visit to the returns article; the answer omits either window; the answer is empty."} +{"web_name": "Target", "id": "Target--1", "ques": "Search for a yoga mat, open the ProsourceFit Extra Thick Yoga and Pilates Mat, and tell me what material it is made of and how long the mat is.", "web": "http://localhost:40016/", "upstream_url": "https://www.target.com/", "verifier_path": "sites/target/verify/verify_1.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST open the ProsourceFit Extra Thick Yoga and Pilates Mat product detail page. (2) The answer MUST report the mat's MATERIAL and its LENGTH, both of which appear only in the detail page's specification table. (3) Price and guest rating are printed on the search result card, so an answer giving those instead of the material and length is a FAIL. FAIL if: no visit to that product page; either the material or the length is missing; the answer is empty."} +{"web_name": "Target", "id": "Target--2", "ques": "In the Grocery department, find Katie's Burrata Margherita Frozen Pizza and tell me how much sodium one serving contains.", "web": "http://localhost:40016/", "upstream_url": "https://www.target.com/", "verifier_path": "sites/target/verify/verify_2.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST open the Katie's Burrata Margherita Frozen Pizza product detail page. (2) The answer MUST state the SODIUM per serving with its unit, taken from the nutrition rows on that page. (3) The figure is not shown on any listing or search page, so a correct number without a visit to the product page is a knowledge shortcut and is a FAIL. FAIL if: no visit to that product page; the sodium value is wrong or missing; the answer is empty."} +{"web_name": "Target", "id": "Target--3", "ques": "Red Baron Pepperoni Classic Crust and Red Baron Four Cheese Classic Crust are both frozen pizzas. Which of the two has less sodium per serving, and what are the two sodium values?", "web": "http://localhost:40016/", "upstream_url": "https://www.target.com/", "verifier_path": "sites/target/verify/verify_3.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST open BOTH the Red Baron Pepperoni Classic Crust and the Red Baron Four Cheese Classic Crust product pages. (2) The answer MUST give BOTH sodium-per-serving values AND state which of the two is lower. (3) Reporting only one value, or naming a winner without both figures, is a FAIL. FAIL if: either product page was not visited; either value is wrong; the comparison names the wrong product; the answer is empty."} +{"web_name": "Target", "id": "Target--4", "ques": "Look up the Mr. Coffee 5 Cup Switch Coffee Maker in Black. What percentage of guests say they would recommend it, and which of its rated attributes scored the highest?", "web": "http://localhost:40016/", "upstream_url": "https://www.target.com/", "verifier_path": "sites/target/verify/verify_4.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST open the Mr. Coffee 5 Cup Switch Coffee Maker (Black) product page. (2) The answer MUST state the percentage of guests who would recommend it AND name which rated attribute scored highest. (3) Both figures live in the guest-ratings block on the detail page and appear on no listing page. FAIL if: no visit to that product page; the percentage is wrong; the wrong attribute is named; the answer is empty."} +{"web_name": "Target", "id": "Target--5", "ques": "Find the Sony WH-1000XM6 Wireless Noise-Canceling Headphones. Two protection plans are offered. How much more does the 3-year plan cost than the 2-year plan, and which of the two covers accidental handling?", "web": "http://localhost:40016/", "upstream_url": "https://www.target.com/", "verifier_path": "sites/target/verify/verify_5.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST open the Sony WH-1000XM6 Wireless Noise-Canceling Headphones product page. (2) The answer MUST quantify how much MORE the 3-year protection plan costs than the 2-year plan (either the difference, or both plan prices). (3) The answer MUST also identify which of the two plans covers accidental handling. FAIL if: no visit to that product page; the price gap is wrong or absent; the wrong plan is named for accidental handling; the answer is empty."} +{"web_name": "Target", "id": "Target--6", "ques": "Browse the Pets department, filter to items that are pickup eligible, and sort by price from low to high. Tell me the name and price of the first product listed.", "web": "http://localhost:40016/", "upstream_url": "https://www.target.com/", "verifier_path": "sites/target/verify/verify_6.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST browse the Pets department AND apply both the pickup-eligible filter and the price low-to-high sort — the trajectory URLs must show both being applied. (2) The answer MUST give the NAME and the PRICE of the first product under that filter and sort. (3) Reading the first item off an unfiltered or unsorted listing is a FAIL even if the named product happens to be correct. FAIL if: either the filter or the sort is missing from the trajectory; the name or price is wrong; the answer is empty."} +{"web_name": "Target", "id": "Target--7", "ques": "I want the frozen pizza with less sodium. Compare Red Baron Supreme Classic Crust against Red Baron Pepperoni Deep Dish Personal, tell me which one has less sodium per serving, and by how many milligrams.", "web": "http://localhost:40016/", "upstream_url": "https://www.target.com/", "verifier_path": "sites/target/verify/verify_7.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST open BOTH the Red Baron Supreme Classic Crust and the Red Baron Pepperoni Deep Dish Personal product pages. (2) The answer MUST name which has less sodium per serving AND quantify the gap (either the difference in milligrams, or both raw values). (3) Naming a winner without any figures is a FAIL. FAIL if: either product page was not visited; the wrong product is named; no figures are given; the answer is empty."} +{"web_name": "Target", "id": "Target--8", "ques": "Sign in as david.k@test.com with password TestPass123!. Open your order history and tell me the order number and total of the order that is still Processing.", "web": "http://localhost:40016/", "upstream_url": "https://www.target.com/", "verifier_path": "sites/target/verify/verify_8.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST sign in with the credentials given in the task and open its order history. (2) The answer MUST give the ORDER NUMBER and the TOTAL of the order whose status is Processing. (3) This is a read-only task: the account's cart, wish list and orders must be unchanged afterwards. FAIL if: order history was never opened; the wrong order is reported; either the order number or the total is missing; the answer is empty."} +{"web_name": "Target", "id": "Target--9", "ques": "Sign in as david.k@test.com with password TestPass123! and tell me how many Target Circle reward points are currently in the account.", "web": "http://localhost:40016/", "upstream_url": "https://www.target.com/", "verifier_path": "sites/target/verify/verify_9.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST sign in with the credentials given in the task and open the rewards page. (2) The answer MUST state the current Target Circle points balance as a number. (3) This is a read-only task: account state must be unchanged afterwards. FAIL if: the rewards page was never opened; the balance is wrong; the answer is empty."} +{"web_name": "Target", "id": "Target--10", "ques": "Sign in as carol.d@test.com with password TestPass123!. Add the ProsourceFit Extra Thick Yoga and Pilates Mat to your cart, then open your cart and tell me the order subtotal shown there.", "web": "http://localhost:40016/", "upstream_url": "https://www.target.com/", "verifier_path": "sites/target/verify/verify_10.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST sign in as the account named in the task, open the ProsourceFit Extra Thick Yoga and Pilates Mat page, and add it to the cart. (2) The cart in the after-state database MUST actually contain that product — claiming success without the row existing is a FAIL. (3) The answer MUST report the cart SUBTOTAL, which is shown only on the cart page; the item count in the header is not the answer. FAIL if: the product is not in the cart afterwards; other cart contents were removed; the subtotal is wrong; the answer is empty."} +{"web_name": "Target", "id": "Target--11", "ques": "Sign in as bob.c@test.com with password TestPass123!. Search for the Tide Ultra Oxi Boost HE Deep Cleaning Concentrated Liquid Laundry Detergent, add it to your cart, and complete checkout as a delivery order shipped to 123 Main St, Denver, CO 80202. Tell me the order number on the confirmation page.", "web": "http://localhost:40016/", "upstream_url": "https://www.target.com/", "verifier_path": "sites/target/verify/verify_11.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST sign in as the account named in the task, add the Tide Ultra Oxi Boost detergent to the cart, and complete checkout as a DELIVERY order to the address given in the task. (2) A NEW order row for that account MUST exist in the after-state database and MUST contain that product — reaching a confirmation-looking page is not sufficient. (3) The order number in the answer MUST match the order actually created. FAIL if: no new order exists; the order does not contain the product; the order is not a delivery order; the reported number differs from the stored one; the answer is empty."} +{"web_name": "Target", "id": "Target--12", "ques": "Sign in as alice.j@test.com with password TestPass123!. Add the Colgate Total Active Prevention Whitening Toothpaste to your wish list, then open the wish list and tell me the name of the item shown at the very bottom of it.", "web": "http://localhost:40016/", "upstream_url": "https://www.target.com/", "verifier_path": "sites/target/verify/verify_12.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST sign in as the account named in the task and add the named toothpaste to the WISH LIST. (2) The wish list in the after-state database MUST contain that product and MUST have grown by exactly one — the control is a toggle, so submitting twice removes it again and is a FAIL. (3) The answer MUST name the product shown LAST on the wish list page, which requires reading the list itself; the count shown on the account page is not the answer, and neither is the item just added. FAIL if: the toothpaste is not on the list afterwards; the count did not grow by one; the named bottom item is wrong; the answer is empty."} +{"web_name": "Target", "id": "Target--13", "ques": "Find the Target store in Denver and tell me its street address and one pickup service it offers.", "web": "http://localhost:40016/", "upstream_url": "https://www.target.com/", "verifier_path": "sites/target/verify/verify_13.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST open the store page for the Denver store. (2) The answer MUST give the store's STREET ADDRESS and name at least one pickup service that store offers. (3) Both facts appear on the store detail page. FAIL if: the store page was never opened; the address is wrong or missing; no service is named; the answer is empty."} +{"web_name": "Target", "id": "Target--14", "ques": "Sign in as alice.j@test.com with password TestPass123!. Remove the Starbucks Medium Roast Ground Coffee — Colombia from your wish list, then tell me the names of the items that are still on the list.", "web": "http://localhost:40016/", "upstream_url": "https://www.target.com/", "verifier_path": "sites/target/verify/verify_14.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST sign in as the account named in the task and open its wish list. (2) The named Starbucks ground coffee MUST be absent from the wish list in the after-state database, and every other saved item MUST still be there — removing the wrong item, or clearing the list, is a FAIL. (3) The answer MUST name the items that remain, which requires reading the list rather than the count on the account page. FAIL if: the named item is still on the list; any other item was removed; the remaining items are misreported; the answer is empty."} +{"web_name": "Target", "id": "Target--15", "ques": "Target carries several Red Baron frozen pizzas. Check the sodium per serving on each of them and tell me which Red Baron pizza has the LOWEST sodium per serving, and what that value is.", "web": "http://localhost:40016/", "upstream_url": "https://www.target.com/", "verifier_path": "sites/target/verify/verify_15.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST open the product pages of the Red Baron frozen pizzas to read each one's sodium per serving — the figure appears on no listing page, so a answer produced without visiting several product pages is a knowledge shortcut. (2) The answer MUST name the single Red Baron pizza with the lowest sodium per serving AND give that value with its unit. (3) Naming a pizza without the figure, or giving a figure that belongs to a different Red Baron pizza, is a FAIL. FAIL if: fewer than two Red Baron product pages were opened; the named pizza is not the lowest; the value is wrong; the answer is empty."} +{"web_name": "Target", "id": "Target--16", "ques": "Compare the Ninja DualBrew Coffee Grounds & Pods GP161 against the Cuisinart 14 Cup Programmable Drip Coffee Maker Stainless Steel. Tell me which one a higher percentage of guests would recommend, and give both percentages.", "web": "http://localhost:40016/", "upstream_url": "https://www.target.com/", "verifier_path": "sites/target/verify/verify_16.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST open BOTH the Ninja DualBrew Coffee Grounds & Pods GP161 and the Cuisinart 14 Cup Programmable Drip Coffee Maker Stainless Steel product pages. (2) The answer MUST give BOTH would-recommend percentages AND name which product has the higher one. (3) These percentages appear only in the guest-ratings block on each detail page. FAIL if: either product page was not visited; either percentage is wrong; the wrong product is named as higher; the answer is empty."} +{"web_name": "Target", "id": "Target--17", "ques": "Sign in as bob.c@test.com with password TestPass123!. Add two of the Colgate Total Active Prevention Whitening Toothpaste to your cart, then complete checkout as a store pickup order at the Denver Stapleton store, choosing the Tomorrow 9:00 AM - 11:00 AM pickup time. Tell me the order number shown on the confirmation page.", "web": "http://localhost:40016/", "upstream_url": "https://www.target.com/", "verifier_path": "sites/target/verify/verify_17.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST sign in as the account named in the task and add TWO of the named toothpaste to the cart. (2) The order placed in the after-state database MUST be a PICKUP order at the store named in the task, booked for the exact pickup time the task specifies, and MUST contain that product with quantity 2. Every store offers the same time windows, so booking the right time at the wrong store is a FAIL. (3) The answer MUST give the order number that was actually created. FAIL if: no new order exists; the order is not a pickup order at the named store; the booked time is not the one requested; the quantity is not 2; the reported order number differs from the stored one; the answer is empty."} +{"web_name": "Target", "id": "Target--18", "ques": "Sign in as bob.c@test.com with password TestPass123!. Your recent order arrived damaged. Contact support about it: open a request with the subject \"Order arrived damaged\", write a short description of the problem in your own words, and choose Email as the contact method. Then confirm the request appears on your account and tell me its status.", "web": "http://localhost:40016/", "upstream_url": "https://www.target.com/", "verifier_path": "sites/target/verify/verify_18.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST sign in as the account named in the task and submit the contact-support form. (2) A NEW support request for that account MUST exist in the after-state database with the exact subject given in the task and Email as the contact method — reaching a confirmation-looking page is not sufficient. (3) The description is written by the agent in its own words and is NOT graded on content; it only has to be present. (4) The answer MUST report the status the request was created with. FAIL if: no new request exists; the subject or contact method differs from what the task specified; the reported status is wrong; the answer is empty."} +{"web_name": "Target", "id": "Target--19", "ques": "Sign in as carol.d@test.com with password TestPass123!. Open the Beats Pill Wireless Bluetooth Speaker and write a 4-star review: use the headline \"Great sound for the size\" and write the review body in your own words. Then confirm your review appears on the product page.", "web": "http://localhost:40016/", "upstream_url": "https://www.target.com/", "verifier_path": "sites/target/verify/verify_19.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST sign in as the account named in the task and open the Beats Pill Wireless Bluetooth Speaker product page. (2) A NEW review for that product MUST exist in the after-state database, authored by that account, with a rating of exactly 4 and the exact headline given in the task. (3) The review body is written by the agent in its own words and is NOT graded on content; it only has to be present. (4) Claiming the review was posted without the row existing is a FAIL, as is a review with a different rating or headline. FAIL if: no new review exists for that product; the rating is not 4; the headline differs; the answer is empty."} diff --git a/sites/target/templates/_account_nav.html b/sites/target/templates/_account_nav.html new file mode 100644 index 00000000..d8e3a5ea --- /dev/null +++ b/sites/target/templates/_account_nav.html @@ -0,0 +1,10 @@ + + + diff --git a/sites/target/templates/_checkout_steps.html b/sites/target/templates/_checkout_steps.html new file mode 100644 index 00000000..888e11da --- /dev/null +++ b/sites/target/templates/_checkout_steps.html @@ -0,0 +1,27 @@ +{# Steps already completed link back, so a guest can return and correct an + earlier answer (an address typo used to mean restarting the whole flow). + The current and future steps stay inert — you can't skip ahead. #} +{# confirmation renders without a checkout dict, so default before reading it #} +{% set _co = checkout if checkout is defined and checkout else {} %} +{% set steps = [ + ('Choose fulfillment', 'checkout'), + ('Shipping / pickup', _co.get('mode') == 'pickup' and 'checkout_pickup' or 'checkout_shipping'), + ('Payment', 'checkout_payment'), + ('Review', 'checkout_review'), + ('Confirmation', none), +] %} +
+ {% for label, endpoint in steps %} + {% if loop.index0 < current_step and endpoint %} + + {{ loop.index }} + {{ label }} + + {% else %} +
+ {{ loop.index }} + {{ label }} +
+ {% endif %} + {% endfor %} +
diff --git a/sites/target/templates/_pagination.html b/sites/target/templates/_pagination.html new file mode 100644 index 00000000..25a304e4 --- /dev/null +++ b/sites/target/templates/_pagination.html @@ -0,0 +1,33 @@ +{# Listing pager. Every link carries the current filters forward so paging + never silently drops a brand / price / availability filter. #} +{% if pagination and pagination.pages > 1 %} + {% set base = request.args.to_dict() %} + +{% endif %} diff --git a/sites/target/templates/_product_cards.html b/sites/target/templates/_product_cards.html new file mode 100644 index 00000000..aa571467 --- /dev/null +++ b/sites/target/templates/_product_cards.html @@ -0,0 +1,44 @@ +
+ {% for product in products %} +
+ + {{ product.name }} + {% if product.deal_badge %} + {{ product.deal_badge }} + {% endif %} + +
+

{{ product.brand.name }} | {{ product.category.name }}

+

{{ product.name }}

+ {# No description blurb here: target.com's own result cards don't carry + one, and echoing the detail copy on the listing leaks detail-page + answers to agents that never open the product. #} +
+ {{ product.rating|stars }} ★ + {{ product.review_count }} ratings +
+
+ {{ product.price|currency }} + {% if product.list_price > product.price %} + {{ product.list_price|currency }} + Save {{ product.discount_percent() }}% + {% endif %} +
+
+ {% if product.pickup_eligible %}Pickup{% endif %} + {% if product.delivery_eligible %}Delivery{% endif %} + {{ product.availability_status }} +
+
+
+ View details +
+ + +
+
+
+ {% endfor %} +
+ + diff --git a/sites/target/templates/account.html b/sites/target/templates/account.html new file mode 100644 index 00000000..9fc66ae9 --- /dev/null +++ b/sites/target/templates/account.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} +{% block title %}Account | Target{% endblock %} +{% block content %} + +{% endblock %} + + diff --git a/sites/target/templates/account_edit.html b/sites/target/templates/account_edit.html new file mode 100644 index 00000000..e9ebbf1b --- /dev/null +++ b/sites/target/templates/account_edit.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} +{% block title %}Edit profile | Target{% endblock %} +{% block content %} + +{% endblock %} diff --git a/sites/target/templates/account_orders.html b/sites/target/templates/account_orders.html new file mode 100644 index 00000000..28e218a1 --- /dev/null +++ b/sites/target/templates/account_orders.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}Orders | Target{% endblock %} +{% block content %} + +{% endblock %} + + diff --git a/sites/target/templates/account_rewards.html b/sites/target/templates/account_rewards.html new file mode 100644 index 00000000..20fb583c --- /dev/null +++ b/sites/target/templates/account_rewards.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block title %}Rewards | Target{% endblock %} +{% block content %} + +{% endblock %} + + diff --git a/sites/target/templates/account_support.html b/sites/target/templates/account_support.html new file mode 100644 index 00000000..820561ce --- /dev/null +++ b/sites/target/templates/account_support.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block title %}Support requests | Target{% endblock %} +{% block content %} + +{% endblock %} diff --git a/sites/target/templates/base.html b/sites/target/templates/base.html new file mode 100644 index 00000000..20b76a65 --- /dev/null +++ b/sites/target/templates/base.html @@ -0,0 +1,133 @@ + + + + + + {% block title %}Target : Expect More. Pay Less.{% endblock %} + + + +
+
+ Free shipping on orders $35+ + Same Day Delivery with Target Circle +
+
+ + + + {% if preferred_store %} +
+
+ Preferred store + {{ preferred_store.name }} + {{ preferred_store.city }}, {{ preferred_store.state }} + View hours & pickup +
+
+ {% endif %} + +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} +
+ + {% block content %}{% endblock %} +
+ + + + + + + + diff --git a/sites/target/templates/cart.html b/sites/target/templates/cart.html new file mode 100644 index 00000000..b78079a2 --- /dev/null +++ b/sites/target/templates/cart.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block title %}Cart | Target{% endblock %} +{% block content %} +
+
+
+

Cart

+

Your cart

+
+
+ {% if requires_login %} +
+

Sign in to view your cart

+

Save your cart and orders by signing in to your Target account.

+ Sign in +
+ {% elif cart_items %} +
+
+ {% for item in cart_items %} + + {% endfor %} +
+ +
+ {% else %} +
+

Your cart is empty

+

Add items from the catalog to continue.

+ Browse products +
+ {% endif %} +
+{% endblock %} + + diff --git a/sites/target/templates/categories.html b/sites/target/templates/categories.html new file mode 100644 index 00000000..c2683ca8 --- /dev/null +++ b/sites/target/templates/categories.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block title %}Categories | Target{% endblock %} +{% block content %} +
+
+
+

Category map

+

Shop by category

+
+
+ {# Grouped by section so the two-level taxonomy is reachable without + hovering the header flyout. #} + {% for section, cats in nav_sections %} +
+

{{ section }}

+
+ {% for category in cats %} + + {{ category.name }} +
+ {{ category.name }} +

{{ category.description }}

+
+
+ {% endfor %} +
+
+ {% endfor %} +
+{% endblock %} + + diff --git a/sites/target/templates/checkout_confirmation.html b/sites/target/templates/checkout_confirmation.html new file mode 100644 index 00000000..c6c57591 --- /dev/null +++ b/sites/target/templates/checkout_confirmation.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}Confirmation | Target{% endblock %} +{% block content %} +
+ {% set current_step = 4 %} + {% include "_checkout_steps.html" %} +
+

Order confirmed

+

Your order {{ order.order_number }} has been created.

+

{{ order.confirmation_note }}

+ + {# The confirmation used to show only the order number, so a guest had no + way to see where or when they were collecting. #} +
+ {% if order.fulfillment_method == 'pickup' %} +
Pickup store
+
{{ order.store.name }} — {{ order.store.city }}, {{ order.store.state }}
+ {% if order.pickup_slot_label %} +
Pickup time
+
{{ order.pickup_slot_label }}
+ {% endif %} + {% else %} +
Shipping to
+
+ {{ order.shipping_name }}{% if order.shipping_street %}, {{ order.shipping_street }}{% endif %}, + {{ order.shipping_city }}, {{ order.shipping_state }} {{ order.shipping_zip }} +
+ {% endif %} +
Order total
+
{{ order.total|currency }}
+
+ +
+
+{% endblock %} + + diff --git a/sites/target/templates/checkout_mode.html b/sites/target/templates/checkout_mode.html new file mode 100644 index 00000000..6efac758 --- /dev/null +++ b/sites/target/templates/checkout_mode.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}Checkout | Target{% endblock %} +{% block content %} +
+ {% set current_step = 0 %} + {% include "_checkout_steps.html" %} +
+
+

Checkout

+

Choose fulfillment for this order

+ {% if requires_login %} +

Sign in to continue with checkout.

+ + {% else %} + + {% endif %} +
+ +
+
+{% endblock %} + + diff --git a/sites/target/templates/checkout_payment.html b/sites/target/templates/checkout_payment.html new file mode 100644 index 00000000..b3caf2fe --- /dev/null +++ b/sites/target/templates/checkout_payment.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}Payment | Target{% endblock %} +{% block content %} +
+ {% set current_step = 2 %} + {% include "_checkout_steps.html" %} +
+
+

Payment

+

Enter payment details

+
+ + + +
+
+
+
+{% endblock %} + + diff --git a/sites/target/templates/checkout_pickup.html b/sites/target/templates/checkout_pickup.html new file mode 100644 index 00000000..ee382ad1 --- /dev/null +++ b/sites/target/templates/checkout_pickup.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block title %}Store Pickup | Target{% endblock %} +{% block content %} +
+ {% set current_step = 1 %} + {% include "_checkout_steps.html" %} +
+
+

Pickup

+

Select a store and pickup slot

+
+ + + +
+
+ +
+
+{% endblock %} + + diff --git a/sites/target/templates/checkout_review.html b/sites/target/templates/checkout_review.html new file mode 100644 index 00000000..517090a8 --- /dev/null +++ b/sites/target/templates/checkout_review.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% block title %}Review Order | Target{% endblock %} +{% block content %} +
+ {% set current_step = 3 %} + {% include "_checkout_steps.html" %} +
+
+

Review

+

Review your order

+ {% for item in cart_items %} +
+
+ {{ item.product.name }} +
+ {{ item.product.name }} +

{{ item.product.price|currency }} | Qty {{ item.quantity }}

+ {% if item.protection_plan %} + {{ item.protection_plan.name }} | {{ item.protection_plan.price|currency }} + {% endif %} +
+
+
+ {% endfor %} +
+ +
+
+ +
+
+{% endblock %} + + diff --git a/sites/target/templates/checkout_shipping.html b/sites/target/templates/checkout_shipping.html new file mode 100644 index 00000000..a2d927a6 --- /dev/null +++ b/sites/target/templates/checkout_shipping.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} +{% block title %}Shipping | Target{% endblock %} +{% block content %} +
+ {% set current_step = 1 %} + {% include "_checkout_steps.html" %} +
+
+

Delivery

+

Choose delivery

+
+ + +
+ + +
+ + + +
+
+ +
+
+{% endblock %} + + diff --git a/sites/target/templates/compare.html b/sites/target/templates/compare.html new file mode 100644 index 00000000..9977c5b9 --- /dev/null +++ b/sites/target/templates/compare.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} +{% block title %}Compare products | Target{% endblock %} +{% block content %} +
+
+
+

Product comparison

+

Compare products

+

Compare up to four products side-by-side to find the best match.

+
+
+ {% if products %} +
+ {% for product in products %} +
+ {# Image and title link back to the product — comparing is useless + if you can't get back to the item you're comparing. #} + + {{ product.name }} + +

{{ product.name }}

+

{{ product.price|currency }}

+
+ + +
+
+ {% endfor %} +
+ {% if spec_rows %} +
+ + + + + {% for product in products %} + + {% endfor %} + + + + {% for label, values in spec_rows %} + + + {% for value in values %} + + {% endfor %} + + {% endfor %} + +
Spec{{ product.name }}
{{ label }}{{ value }}
+
+ {% else %} + {# Only rows at least two products share are kept, so an empty table + means these items have nothing in common to line up. #} +
+

No comparable specifications

+ {% if compared_categories|length > 1 %} +

+ These items come from different categories + ({{ compared_categories|join(', ') }}), so they share no specifications. + Compare products from the same category to line up their details. +

+ {% else %} +

These items don't list any specifications in common.

+ {% endif %} +
+ {% endif %} + {% else %} +
+

No products in compare

+

Use product pages or category listings to add items into the compare tray.

+
+ {% endif %} +
+{% endblock %} + + diff --git a/sites/target/templates/deals.html b/sites/target/templates/deals.html new file mode 100644 index 00000000..437c594f --- /dev/null +++ b/sites/target/templates/deals.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block title %}Deals | Target{% endblock %} +{% block content %} +
+
+
+

Current offers

+

This week's deals

+
+
+
+ {% for deal in deals %} +
+ {% if deal.product and deal.product.image_path %} + + {{ deal.product.name }} + + {% endif %} +

{{ deal.badge }}

+

{{ deal.title }}

+

{{ deal.subtitle }}

+ {% if deal.product %} +

+ ${{ '%.2f'|format(deal.product.price) }} + {% if deal.product.list_price and deal.product.list_price > deal.product.price %} + ${{ '%.2f'|format(deal.product.list_price) }} + {% endif %} +

+ {% endif %} +
+ {{ deal.discount_percent }}% off + {{ deal.ends_label }} +
+ {% if deal.product %} + Open product + {% endif %} +
+ {% endfor %} +
+
+{% endblock %} + + diff --git a/sites/target/templates/home.html b/sites/target/templates/home.html new file mode 100644 index 00000000..5b487d02 --- /dev/null +++ b/sites/target/templates/home.html @@ -0,0 +1,133 @@ +{% extends "base.html" %} +{% block title %}Target : Expect More. Pay Less.{% endblock %} +{% block content %} +
+
+
+

Top deals

+

Save on everything you need for home, style and everyday essentials.

+

Shop thousands of items with free Order Pickup and Drive Up in as little as an hour, plus 5% off every purchase with Target Circle.

+ +
+
+ {# Real product photography from the catalog — no placeholder graphics. #} +
+ {% for product in featured_products[:4] %} + + {{ product.name }} + + {% endfor %} +
+
+
+
+ +
+ {% for category in categories[:4] %} + + {{ category.name }} +
+ {# Baby / Beauty are their own section, so don't render "Baby Baby". #} + {% if category.section != category.name %}{{ category.section }}{% endif %} + {{ category.name }} +
+
+ {% endfor %} +
+ +
+
+
+

Featured deals

+

This week's top deals

+
+ See all deals +
+
+ {% for deal in deal_cards %} +
+ {% if deal.product and deal.product.image_path %} + + {{ deal.product.name }} + + {% endif %} +

{{ deal.badge }}

+

{{ deal.title }}

+

{{ deal.subtitle }}

+ {% if deal.product %} +

+ ${{ '%.2f'|format(deal.product.price) }} + {% if deal.product.list_price and deal.product.list_price > deal.product.price %} + ${{ '%.2f'|format(deal.product.list_price) }} + {% endif %} +

+ {% endif %} +
+ Save {{ deal.discount_percent }}% + {{ deal.ends_label }} +
+ {% if deal.product %} + Open product + {% endif %} +
+ {% endfor %} +
+
+ +
+
+
+

Trending now

+

Local bestsellers across home, style, kids, and everyday essentials

+
+
+ {% set products = featured_products %} + {% include "_product_cards.html" %} +
+ +
+ +
+
+
+

Help topics

+

Help center

+
+
+
+ {% for article in support_articles %} + + {{ article.title }} +

{{ article.summary }}

+ {{ article.topic }} +
+ {% endfor %} +
+
+
+{% endblock %} + + diff --git a/sites/target/templates/login.html b/sites/target/templates/login.html new file mode 100644 index 00000000..f69ac03d --- /dev/null +++ b/sites/target/templates/login.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}Sign in | Target{% endblock %} +{% block content %} +
+
+

Sign in

+

Sign in to your Target account

+
+ + + +
+

Demo account: alice.j@test.com / {{ benchmark_password }}

+
+
+

New to Target?

+

Create an account to save your cart, track orders, and earn rewards.

+ Create account +
+
+{% endblock %} + + diff --git a/sites/target/templates/order_detail.html b/sites/target/templates/order_detail.html new file mode 100644 index 00000000..076de152 --- /dev/null +++ b/sites/target/templates/order_detail.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block title %}{{ order.order_number }} | Target{% endblock %} +{% block content %} +
+
+
+ {# Signed-in guests arrive here from order history and previously had + no way back; anonymous order-lookup users get the lookup form. #} + {% if current_user.is_authenticated %} + ← Back to order history + {% else %} + ← Back to order lookup + {% endif %} +

Order detail

+

{{ order.order_number }}

+

{{ order.status }} | {{ order.fulfillment_method|title }} | {{ order.placed_at.strftime('%b %d, %Y') }}

+
+
{{ order.total|currency }}
+
+
+ + +
+
+{% endblock %} + + diff --git a/sites/target/templates/order_lookup.html b/sites/target/templates/order_lookup.html new file mode 100644 index 00000000..dda5213b --- /dev/null +++ b/sites/target/templates/order_lookup.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}Order Lookup | Target{% endblock %} +{% block content %} +
+
+

Order lookup

+

Track your order

+
+ + + +
+
+
+

How it works

+
    +
  • Enter your order number and email address
  • +
  • View your order status and estimated delivery date
  • +
  • Track shipments and arrange returns online
  • +
+
+
+{% endblock %} + + diff --git a/sites/target/templates/product_detail.html b/sites/target/templates/product_detail.html new file mode 100644 index 00000000..7242fbff --- /dev/null +++ b/sites/target/templates/product_detail.html @@ -0,0 +1,207 @@ +{% extends "base.html" %} +{% block title %}{{ product.name }} | Target{% endblock %} +{% block content %} +
+
+ {{ product.name }} +
+
+

{{ product.brand.name }} | {{ product.category.name }}

+

{{ product.name }}

+
+ {{ product.rating|stars }} ★ + {{ product.review_count }} ratings +
+
+ {{ product.price|currency }} + {% if product.list_price > product.price %} + {{ product.list_price|currency }} + Save {{ product.discount_percent() }}% + {% endif %} +
+

{{ product.short_description }}

+
    + {% for bullet in product.highlights() %} +
  • {{ bullet }}
  • + {% endfor %} +
+
+ {{ product.availability_status }} + {% if product.pickup_eligible %}Store pickup{% endif %} + {% if product.delivery_eligible %}Delivery{% endif %} +
+
+
+ + + + +
+
+ + +
+ {% if current_user.is_authenticated %} +
+ + +
+ {% endif %} +
+
+ +
+ +
+
+
+
+

Tech specs

+

What to compare

+
+
+ {% for section in product.specs() %} +
+

{{ section['title'] }}

+
+ {% for item in section['items'] %} +
{{ item['label'] }}
+
{{ item['value'] }}
+ {% endfor %} +
+
+ {% endfor %} +
+
+
+
+

Protection plans

+

Compare coverage

+
+
+
+ {% for plan in product.protection_plans %} +
+

{{ plan.name }}

+ {{ plan.price|currency }} +

{{ plan.coverage_summary }}

+
    +
  • {{ plan.years }} years of coverage
  • +
  • {% if plan.accidental %}Includes accidental handling claims{% else %}Mechanical and power issue coverage{% endif %}
  • +
  • Priority support: {% if plan.priority_support %}Included{% else %}Not included{% endif %}
  • +
+
+ {% endfor %} +
+
+
+ +
+
+
+
+

Reviews

+

Guest ratings & reviews

+
+
+ + {# Per-attribute ratings and the recommendation rate live only here, never + on a search card — see the scraped secondary_ratings / percent_recommended. #} + {% if product.secondary_ratings() or product.percent_recommended %} +
+ {% if product.percent_recommended %} +

+ {{ product.percent_recommended }}% would recommend +

+ {% endif %} + {% for label, score in product.secondary_ratings().items() %} +
+ {{ label }} + + + + {{ score }} out of 5 +
+ {% endfor %} +
+ {% endif %} + + {% if current_user.is_authenticated %} +
+ Write a review +
+ + + + +
+
+ {% else %} +

+ Sign in + to write a review. +

+ {% endif %} + + {% if not product.reviews %} +

No written guest reviews yet for this item.

+ {% endif %} + + {% for review in product.reviews[:6] %} +
+
+ {{ review.title }} + {{ review.rating }} ★ +
+

{{ review.body }}

+ {{ review.author_name }} | {% if review.verified %}Verified purchase{% else %}Target guest{% endif %} +
+ {% endfor %} +
+
+
+
+

You may also like

+

Related category picks

+
+
+ {% set products = related_products %} + {% include "_product_cards.html" %} +
+
+{% endblock %} + + diff --git a/sites/target/templates/products.html b/sites/target/templates/products.html new file mode 100644 index 00000000..14ccc15e --- /dev/null +++ b/sites/target/templates/products.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} +{% block title %}{{ page_title }} | Target{% endblock %} +{% block content %} +
+
+
+

{{ category.section if category else 'Shop products' }}

+

{{ page_title }}

+

{{ page_description }}

+
+
{{ products|length }} items
+
+
+ +
+ {% if products %} + {% if pagination %} +

+ Showing {{ pagination.items|length }} of {{ pagination.total }} items + (page {{ pagination.page }} of {{ pagination.pages }}) +

+ {% endif %} + {% include "_product_cards.html" %} + {% include "_pagination.html" %} + {% else %} +
+

No products matched those filters

+

Try widening the price range or clearing one of the availability filters.

+
+ {% endif %} +
+
+
+{% endblock %} + + diff --git a/sites/target/templates/register.html b/sites/target/templates/register.html new file mode 100644 index 00000000..bce48d01 --- /dev/null +++ b/sites/target/templates/register.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% block title %}Register | Target{% endblock %} +{% block content %} +
+
+

Register

+

Create your Target account

+
+ + + + + +
+ + +
+ + +
+
+
+

Why register?

+
    +
  • Save your wishlist, cart, and rewards.
  • +
  • Track your orders and manage returns.
  • +
  • Earn points with every purchase.
  • +
+
+
+{% endblock %} + + diff --git a/sites/target/templates/search.html b/sites/target/templates/search.html new file mode 100644 index 00000000..11372c88 --- /dev/null +++ b/sites/target/templates/search.html @@ -0,0 +1,129 @@ +{% extends "base.html" %} +{% block title %}Search | Target{% endblock %} +{% block content %} +
+
+
+

Global search

+

Results for "{{ active_query }}"

+
+
+ {% if active_query %} +
+
+

Products

+
+ +
+ {% if product_results %} + {% if pagination %} +

+ Showing {{ pagination.items|length }} of {{ pagination.total }} items + (page {{ pagination.page }} of {{ pagination.pages }}) +

+ {% endif %} + {% set products = product_results %} + {% include "_product_cards.html" %} + {% include "_pagination.html" %} + {% else %} +

No product matches found.

+ {% endif %} +
+
+
+
+

Stores

+
+ {% for store in store_results %} + + {% if store.image_path %}{{ store.name }}{% endif %} +
+ {{ store.name }} +

{{ store.city }}, {{ store.state }}

+
+
+ {% else %} +

No store matches found.

+ {% endfor %} +
+
+
+

Support

+
+ {% for article in article_results %} + + {{ article.title }} +

{{ article.summary }}

+
+ {% else %} +

No support articles matched.

+ {% endfor %} +
+
+
+ {% else %} +
+

Search the local retail catalog

+

Try "OLED laptop", "pickup", "protection plan", or "same-day delivery".

+
+ {% endif %} +
+{% endblock %} + + diff --git a/sites/target/templates/store_detail.html b/sites/target/templates/store_detail.html new file mode 100644 index 00000000..7e4606e4 --- /dev/null +++ b/sites/target/templates/store_detail.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% block title %}{{ store.name }} | Target{% endblock %} +{% block content %} +
+ {% if store.image_path %} +
+ {{ store.name }} +
+ {% endif %} +
+

Store details

+

{{ store.name }}

+

{{ store.address }} | {{ store.city }}, {{ store.state }}

+

{{ store.phone }}

+

{{ store.hero_copy }}

+

Hours

+
    + {% for hour in store.hours() %} +
  • {{ hour }}
  • + {% endfor %} +
+

Amenities

+
    + {% for amenity in store.amenities() %} +
  • {{ amenity }}
  • + {% endfor %} +
+
+
+ +
+
+
+

Pickup-ready inventory

+

Popular local items at this store

+
+
+
+ + + + + + + + + + + {% for row in inventory_rows %} + + + + + + + {% endfor %} + +
ProductPickup windowQtyAisle
{{ row.product.name }}{{ row.pickup_window }}{{ row.quantity }}{{ row.aisle }}
+
+
+{% endblock %} + + diff --git a/sites/target/templates/stores.html b/sites/target/templates/stores.html new file mode 100644 index 00000000..06d5b89b --- /dev/null +++ b/sites/target/templates/stores.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block title %}Stores | Target{% endblock %} +{% block content %} +
+
+
+

Store lookup

+

Find stores, pickup desks, and amenities

+
+ +
+ +
+{% endblock %} + + diff --git a/sites/target/templates/support.html b/sites/target/templates/support.html new file mode 100644 index 00000000..7492503c --- /dev/null +++ b/sites/target/templates/support.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}Support | Target{% endblock %} +{% block content %} +
+
+
+

Support center

+

Support articles for shipping, pickup, rewards, and protection plans

+
+ +
+
+ All topics + {% for topic in topics %} + {{ topic }} + {% endfor %} +
+
+ {% for article in articles %} + + {{ article.topic }} + {{ article.title }} +

{{ article.summary }}

+
+ {% endfor %} +
+
+{% endblock %} + + diff --git a/sites/target/templates/support_article.html b/sites/target/templates/support_article.html new file mode 100644 index 00000000..170d8eb8 --- /dev/null +++ b/sites/target/templates/support_article.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block title %}{{ article.title }} | Target{% endblock %} +{% block content %} +
+
+

{{ article.topic }}

+

{{ article.title }}

+

{{ article.summary }}

+ {% for paragraph in article.body|paragraphs %} +

{{ paragraph }}

+ {% endfor %} + {% if article.upstream_url %} +

Reference upstream topic

+ {% endif %} +
+ +
+{% endblock %} + + diff --git a/sites/target/templates/support_contact.html b/sites/target/templates/support_contact.html new file mode 100644 index 00000000..4eaa78e9 --- /dev/null +++ b/sites/target/templates/support_contact.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}Contact us | Target{% endblock %} +{% block content %} +
+
+
+

Help

+

Contact us

+

Tell us what went wrong and we'll open a request on your account.

+
+ Your requests +
+
+
+ + + + +
+
+
+{% endblock %} diff --git a/sites/target/templates/wishlist.html b/sites/target/templates/wishlist.html new file mode 100644 index 00000000..49d49fee --- /dev/null +++ b/sites/target/templates/wishlist.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}Wishlist | Target{% endblock %} +{% block content %} + +{% endblock %} + + diff --git a/sites/target/verify/README.md b/sites/target/verify/README.md new file mode 100644 index 00000000..234b00ce --- /dev/null +++ b/sites/target/verify/README.md @@ -0,0 +1,72 @@ +# Target — grading contract + +One deterministic verifier per task in `sites/target/tasks.jsonl`, plus the +shared helpers in `verify_lib.py`. Each task row points here through +`verifier_path`; the matching `judge_rubric` in that row drives the LLM judge. + +## Layout + +``` +verify_lib.py shared utilities (trajectory, answer matching, SQLite state, + anchored LLM helpers, the Judge harness, CLI parsing) +verify_0.py … one per task, ground truth HARDCODED inside +verify_17.py +``` + +Ground truth never lives in `tasks.jsonl` — the agent reads that file. + +## Running one + +```bash +cd agent_demo +uv run python ../sites/target/verify/verify_2.py \ + --run_dir runs/target2 \ + --initial_db --after_db \ + --no_llm True # deterministic-only; omit to include LLM checks +``` + +Or through the single evaluation entry point: + +```bash +uv run python agent_demo/eval_judge.py --run_dir runs/target2 --verifier True +``` + +Output is `{task_id, pass, reason, evidence[]}` on stdout; exit 0 = PASS. +`--no_llm True` short-circuits every `llm_*` helper, so a deterministic-only +run makes zero API calls and needs no key. + +## What the verifiers check + +Deterministic first, in this order: + +1. **Navigation** — the agent actually opened the page carrying the answer. A + correct answer with no matching navigation is a knowledge shortcut and + fails. This matters on a retail mirror: a model may know Red Baron pizza + exists, but it cannot know THIS mirror's sodium rows or plan prices. +2. **Answer match** — numeric/token comparison against frozen ground truth. + Money is compared below one cent, so $249.98 does not satisfy $249.99. +3. **Database after-state** — for stateful tasks, the cart/wish-list/order rows + are diffed between the initial and after DBs. Claiming a cart addition that + never landed fails on state alone, regardless of wording. +4. **LLM helpers** — only where exact matching is brittle, always anchored on + the frozen ground truth, one call each, all skippable with `--no_llm`. + +Read-only tasks additionally assert the DB was *not* mutated. + +## Disambiguation tasks + +`Target--14` and `Target--15` are deliberately under-specified (several +wish-list items; several Red Baron pizzas with different sodium). They pass +only when the agent asks which one is meant. `Target--14` also asserts nothing +was removed while waiting — silently guessing is visible in the database even +if the reply sounds cautious. + +## Validation performed + +- **No-op run** (homepage only, empty answer, clean DB): all 18 verifiers FAIL. +- **Shortcut** (right answer, no navigation): FAIL. +- **Wrong answer** (page visited, value wrong): FAIL. +- **Claimed-but-not-done** (cart wording says added, DB unchanged): FAIL. +- **Disambiguation guessed** (single value quoted, item deleted): FAIL. +- **Filter/sort skipped** (right product read off an unsorted listing): FAIL. +- Correct runs for each of the above: PASS. diff --git a/sites/target/verify/verify_0.py b/sites/target/verify/verify_0.py new file mode 100644 index 00000000..aeb39a70 --- /dev/null +++ b/sites/target/verify/verify_0.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for Target--0. + +Find the returns Help topic and report the opened-beauty window and the +Target-owned-brand window. + +Ground truth (frozen here, never read from tasks.jsonl): + Support article 'returns-and-exchanges' body: + "Most items can be returned within 90 days. Target owned brands carry a + one-year return window with a receipt. Opened beauty items can be + returned within 60 days." + -> opened beauty = 60 days; Target owned brands = one year. + +The task deliberately does NOT ask for the 90-day figure: that sentence is the +article's summary, which the search results page prints verbatim, so it is +answerable without ever opening the article. The 60-day and one-year facts +appear only in the body. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, last_shot, visited_product, navigated_to, + has_number, contains_any, contains_all, resolve_db, + db_unchanged_for, llm_text_match, llm_screenshot_shows, + Judge, parse_args) + + +def main(): + a = parse_args() + j = Judge("Target--0", a.no_llm) + t = load_run(a.run_dir) + fa = final_answer(t) + + j.check("opened_returns_article", navigated_to(t, "/support/returns-and-exchanges"), + f"visited={navigated_to(t, '/support/returns-and-exchanges')}") + beauty_ok = has_number(fa, 60) + # A year is equally correct written as "one year", "12 months" or "365 days". + # Only accepting the words made a correct "365 days" answer fail. + year_ok = (contains_any(fa, ["one year", "one-year", "1 year", "1-year", + "a year", "12 month", "twelve month"]) + or has_number(fa, 365) or has_number(fa, 12)) + j.check("answer_states_opened_beauty_window", beauty_ok, f"final={fa!r}") + j.check("answer_states_owned_brand_window", year_ok, f"final={fa!r}") + + # The article's summary — "Most items can be returned within 90 days" — is + # printed verbatim on the search results page. An answer built only from it + # never opened the article, so require BOTH body facts explicitly rather + # than relying on the 90 check to catch it. + j.check("not_only_the_summary_fact", beauty_ok and year_ok, + f"answer must carry both body facts, not just the 90-day summary: final={fa!r}") + + initial = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + unchanged = db_unchanged_for(initial, after, "alice.j@test.com") + j.check("read_only_task_left_db_alone", unchanged is True, + f"db_unchanged={unchanged}") + + ok, ev = llm_text_match(fa, "opened beauty items: 60 days; Target owned brands: one year with a receipt", + "How long to return an opened beauty item, and what is the Target owned brand window?") + j.check("answer_matches_ground_truth", ok, ev, llm=True) + + s = last_shot(t) + if s: + ok, ev = llm_screenshot_shows(s, "a 60 day window for opened beauty items and a one-year window for Target owned brands", + "the returns and exchanges article body") + j.check("screenshot_shows_answer", ok, ev, llm=True) + else: + j.check("screenshot_shows_answer", False, "no screenshots in run") + + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/target/verify/verify_1.py b/sites/target/verify/verify_1.py new file mode 100644 index 00000000..2046cda9 --- /dev/null +++ b/sites/target/verify/verify_1.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for Target--1. + +Open the ProsourceFit Extra Thick Yoga and Pilates Mat and report its material +and its length. + +Ground truth (frozen here, never read from tasks.jsonl): + SKU TGT91151386 + Material: Nitrile Butadiene Rubber + Dimensions (Overall): 71 inches (L), 24.0 inches (W), 25.4 millimeter thick + +The task used to ask for price and rating. Both are printed on the search +result card, so it was solvable without opening the product at all — a leak by +review-env's definition. Material and length appear only in the detail page's +specification table. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, last_shot, visited_product, navigated_to, + has_number, contains_any, contains_all, resolve_db, + db_unchanged_for, llm_text_match, llm_screenshot_shows, + Judge, parse_args) + + +def main(): + a = parse_args() + j = Judge("Target--1", a.no_llm) + t = load_run(a.run_dir) + fa = final_answer(t) + + SKU = "TGT91151386" + j.check("opened_product_page", visited_product(t, SKU), + f"visited /product/{SKU}={visited_product(t, SKU)}") + j.check("answer_has_material", + contains_any(fa, ["nitrile butadiene rubber", "nitrile butadiene", "nbr"]), + f"final={fa!r}") + j.check("answer_has_length", has_number(fa, 71), f"final={fa!r}") + # Price/rating are card-level facts; quoting them instead of the spec means + # the detail page was not actually read. + j.check("not_card_level_answer_only", + not (has_number(fa, 29.99) and not has_number(fa, 71)), + f"answer looks like the search card only: final={fa!r}") + + initial = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + unchanged = db_unchanged_for(initial, after, "alice.j@test.com") + j.check("read_only_task_left_db_alone", unchanged is True, + f"db_unchanged={unchanged}") + + ok, ev = llm_text_match(fa, "Nitrile Butadiene Rubber; 71 inches long", + "What material is the ProsourceFit yoga mat made of, and how long is it?") + j.check("answer_matches_ground_truth", ok, ev, llm=True) + + s = last_shot(t) + if s: + ok, ev = llm_screenshot_shows(s, "a specification table listing Nitrile Butadiene Rubber and 71 inches", + "the yoga mat specification table") + j.check("screenshot_shows_answer", ok, ev, llm=True) + else: + j.check("screenshot_shows_answer", False, "no screenshots in run") + + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/target/verify/verify_10.py b/sites/target/verify/verify_10.py new file mode 100644 index 00000000..8592ba20 --- /dev/null +++ b/sites/target/verify/verify_10.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for Target--10 (stateful: add to cart). + +Sign in as carol.d@test.com, add the ProsourceFit Extra Thick Yoga and Pilates +Mat to the cart, then report the cart subtotal. + +Ground truth (frozen here, never read from tasks.jsonl): + SKU TGT91151386 ($29.99). carol.d starts with a $378.97 subtotal, so the + subtotal afterwards is $408.96. + +The task asks for the SUBTOTAL, not the item count: the header already shows a +cart count, so a count question would have been answerable without opening the +cart at all. The subtotal is recomputed from the DB rather than hardcoded, so +the check still holds if the seed's starting cart changes. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, last_shot, visited_product, + navigated_to, has_money, resolve_db, cart_skus, cart_for, + db_query, llm_screenshot_shows, Judge, parse_args) + +EMAIL = "carol.d@test.com" +SKU = "TGT91151386" + + +def main(): + a = parse_args() + j = Judge("Target--10", a.no_llm) + t = load_run(a.run_dir) + fa = final_answer(t) + + j.check("opened_product_page", visited_product(t, SKU), + f"visited /product/{SKU}={visited_product(t, SKU)}") + j.check("opened_cart", navigated_to(t, "/cart"), + f"visited_cart={navigated_to(t, '/cart')}") + + initial = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + before_skus = cart_skus(initial, EMAIL) + after_skus = cart_skus(after, EMAIL) + + j.check("db_readable", before_skus is not None and after_skus is not None, + f"before={before_skus} after={after_skus}") + + # Subtotal = sum(price * quantity) over the after-state cart. + subtotal_after = 0.0 + if after is not None: + for sku, _name, qty in (cart_for(after, EMAIL) or []): + row = db_query(after, "SELECT price FROM products WHERE sku=?", (sku,)) + if row: + subtotal_after += row[0][0] * qty + subtotal_after = round(subtotal_after, 2) + + if before_skus is not None and after_skus is not None: + j.check("product_now_in_cart", SKU in after_skus, + f"cart after={after_skus}") + j.check("cart_grew_by_one", len(after_skus) == len(before_skus) + 1, + f"before={len(before_skus)} after={len(after_skus)}") + # Nothing else should have been added or removed along the way. + j.check("no_other_cart_changes", + set(after_skus) - set(before_skus) == {SKU} and + not set(before_skus) - set(after_skus), + f"added={set(after_skus)-set(before_skus)} removed={set(before_skus)-set(after_skus)}") + j.check("answer_reports_subtotal", has_money(fa, subtotal_after), + f"expected subtotal={subtotal_after:.2f} final={fa!r}") + else: + for name in ("product_now_in_cart", "cart_grew_by_one", + "no_other_cart_changes", "answer_reports_subtotal"): + j.check(name, False, "DB unavailable") + + s = last_shot(t) + if s: + ok, ev = llm_screenshot_shows(s, "the yoga mat in the shopping cart", + "the cart contents after adding the item") + j.check("screenshot_shows_cart", ok, ev, llm=True) + else: + j.check("screenshot_shows_cart", False, "no screenshots in run") + + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/target/verify/verify_11.py b/sites/target/verify/verify_11.py new file mode 100644 index 00000000..c4b48c0d --- /dev/null +++ b/sites/target/verify/verify_11.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for Target--11 (stateful: full checkout). + +Sign in as bob.c@test.com, add the Tide Ultra Oxi Boost detergent to the cart, +complete checkout with delivery to 123 Main St, Denver, CO 80202, and report +the order number from the confirmation page. + +Ground truth: SKU TGT94640332 (Tide Ultra Oxi Boost HE Deep Cleaning +Concentrated Liquid Laundry Detergent). The order number is NOT fixed — it is allocated at checkout — so +the check is that a NEW order row exists for this user containing that SKU, +and that the number the agent reported is the one that actually landed in the +DB. That closes the obvious cheat: claiming a plausible order number without +completing the flow. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, last_shot, visited_product, + navigated_to, contains_any, resolve_db, new_orders, + order_items, llm_screenshot_shows, Judge, parse_args) + +EMAIL = "bob.c@test.com" +SKU = "TGT94640332" + + +def main(): + a = parse_args() + j = Judge("Target--11", a.no_llm) + t = load_run(a.run_dir) + fa = final_answer(t) + + j.check("opened_product_page", visited_product(t, SKU), + f"visited /product/{SKU}={visited_product(t, SKU)}") + j.check("reached_confirmation", navigated_to(t, "/checkout/confirmation"), + f"confirmation_visited={navigated_to(t, '/checkout/confirmation')}") + + initial = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + created = new_orders(initial, after, EMAIL) + + j.check("db_has_new_order", bool(created), + f"new orders for {EMAIL}: {created}") + + if created: + # Exactly one new order, and it must contain the requested product. + j.check("exactly_one_new_order", len(created) == 1, + f"count={len(created)}") + number = created[0][0] + items = order_items(after, number) or [] + j.check("order_contains_product", any(r[0] == SKU for r in items), + f"order {number} items={items}") + # The reported number must be the one the DB actually recorded. + j.check("answer_reports_real_order_number", number.lower() in (fa or "").lower(), + f"db_order={number} final={fa!r}") + j.check("order_is_delivery", created[0][3] == "delivery", + f"fulfillment={created[0][3]}") + else: + for name in ("exactly_one_new_order", "order_contains_product", + "answer_reports_real_order_number", "order_is_delivery"): + j.check(name, False, "no new order was created") + + s = last_shot(t) + if s: + ok, ev = llm_screenshot_shows(s, "an order confirmation with an order number", + "the checkout confirmation page") + j.check("screenshot_shows_confirmation", ok, ev, llm=True) + else: + j.check("screenshot_shows_confirmation", False, "no screenshots in run") + + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/target/verify/verify_12.py b/sites/target/verify/verify_12.py new file mode 100644 index 00000000..4f131937 --- /dev/null +++ b/sites/target/verify/verify_12.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for Target--12 (stateful: add to wish list). + +Sign in as alice.j@test.com, add the Colgate Total Active Prevention Whitening +Toothpaste to the wish list, then report the item shown at the BOTTOM of it. + +Ground truth (frozen here, never read from tasks.jsonl): + SKU TGT1012287965 (the toothpaste being added). + The wish list renders newest-first, so the bottom row is the OLDEST entry: + TGT84640745, "Organic Mini Sandwich Cheddar Cheese Crackers - 8oz/8ct". + The newly added toothpaste lands at the TOP, not the bottom. + +The task asks for the bottom item rather than a count: the account page already +prints "Wishlist items N", so a count question would have been answerable +without opening the list. Naming the bottom row requires reading it. + +The wish-list route is a TOGGLE, so a double submit would silently undo the +add. Asserting the final membership plus an exact +1 delta catches that. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, last_shot, visited_product, + navigated_to, contains_any, resolve_db, wishlist_skus, + llm_screenshot_shows, Judge, parse_args) + +EMAIL = "alice.j@test.com" +SKU = "TGT1012287965" +BOTTOM_SKU = "TGT84640745" + + +def main(): + a = parse_args() + j = Judge("Target--12", a.no_llm) + t = load_run(a.run_dir) + fa = final_answer(t) + + j.check("opened_product_page", visited_product(t, SKU), + f"visited /product/{SKU}={visited_product(t, SKU)}") + j.check("opened_wishlist", navigated_to(t, "/account/wishlist"), + f"visited={navigated_to(t, '/account/wishlist')}") + + initial = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + before = wishlist_skus(initial, EMAIL) + now = wishlist_skus(after, EMAIL) + + j.check("db_readable", before is not None and now is not None, + f"before={before} after={now}") + + if before is not None and now is not None: + j.check("product_on_wishlist", SKU in now, f"wishlist after={now}") + j.check("wishlist_grew_by_one", len(now) == len(before) + 1, + f"before={len(before)} after={len(now)} " + f"(a toggled-twice run lands back at {len(before)})") + j.check("nothing_else_removed", not set(before) - set(now), + f"removed={set(before)-set(now)}") + j.check("answer_names_bottom_item", + contains_any(fa, ["organic mini sandwich", "cheddar cheese crackers"]), + f"expected the oldest entry ({BOTTOM_SKU}) final={fa!r}") + # Naming the item it just added means the list was never read. + j.check("did_not_name_the_added_item", + not contains_any(fa, ["colgate"]) or + contains_any(fa, ["organic mini sandwich", "cheddar cheese crackers"]), + f"final={fa!r}") + else: + for name in ("product_on_wishlist", "wishlist_grew_by_one", + "nothing_else_removed", "answer_names_bottom_item", + "did_not_name_the_added_item"): + j.check(name, False, "DB unavailable") + + s = last_shot(t) + if s: + ok, ev = llm_screenshot_shows(s, "the toothpaste on the wish list", + "the wish list after adding the item") + j.check("screenshot_shows_wishlist", ok, ev, llm=True) + else: + j.check("screenshot_shows_wishlist", False, "no screenshots in run") + + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/target/verify/verify_13.py b/sites/target/verify/verify_13.py new file mode 100644 index 00000000..078fbbeb --- /dev/null +++ b/sites/target/verify/verify_13.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for Target--13. + +Find the Denver store and report its address and a pickup service. + +Ground truth (frozen here, never read from tasks.jsonl): + Store 'denver-stapleton', address 7400 E 29th Ave.\n Amenities: Curbside pickup, Gift wrapping, Mobile checkout. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, last_shot, visited_product, navigated_to, + has_number, contains_any, contains_all, resolve_db, + db_unchanged_for, llm_text_match, llm_screenshot_shows, + Judge, parse_args) + + +def main(): + a = parse_args() + j = Judge("Target--13", a.no_llm) + t = load_run(a.run_dir) + fa = final_answer(t) + + j.check("opened_store_page", navigated_to(t, "/stores/denver-stapleton"), + f"visited={navigated_to(t, '/stores/denver-stapleton')}") + j.check("answer_has_address", contains_all(fa, ["7400", "29th"]), f"final={fa!r}") + j.check("answer_has_a_service", + contains_any(fa, ["curbside", "gift wrapping", "mobile checkout", "pickup"]), + f"final={fa!r}") + + initial = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + unchanged = db_unchanged_for(initial, after, "alice.j@test.com") + j.check("read_only_task_left_db_alone", unchanged is True, + f"db_unchanged={unchanged}") + + ok, ev = llm_text_match(fa, "Denver Stapleton, 7400 E 29th Ave, offering Curbside pickup", "What is the Denver store's address and a pickup service it offers?") + j.check("answer_matches_ground_truth", ok, ev, llm=True) + + s = last_shot(t) + if s: + ok, ev = llm_screenshot_shows(s, "the store address 7400 E 29th Ave", "What is the Denver store's address and a pickup service it offers?") + j.check("screenshot_shows_answer", ok, ev, llm=True) + else: + j.check("screenshot_shows_answer", False, "no screenshots in run") + + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/target/verify/verify_14.py b/sites/target/verify/verify_14.py new file mode 100644 index 00000000..1fac9064 --- /dev/null +++ b/sites/target/verify/verify_14.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for Target--14 (stateful: remove a named wish-list item). + +Sign in as alice.j@test.com, remove the named Starbucks ground coffee from the +wish list, and report which items remain on it. + +Ground truth (frozen here, never read from tasks.jsonl): + SKU TGT12954143 (Starbucks Medium Roast Ground Coffee — Colombia). + alice.j starts with 4 wish-list rows -> 3 remain. + +The task names the item explicitly. An earlier version said "remove an item" +and graded on whether the agent asked which one — CONTRIBUTING rejects tasks +that presuppose a human in the loop, so the ambiguity was removed and the +grading moved onto the database instead. + +Removing the WRONG item is the failure this is built to catch: the count would +still be 3 and a text-only check would pass it. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, last_shot, navigated_to, + contains_any, resolve_db, wishlist_skus, + llm_screenshot_shows, Judge, parse_args) + +EMAIL = "alice.j@test.com" +SKU = "TGT12954143" + + +def main(): + a = parse_args() + j = Judge("Target--14", a.no_llm) + t = load_run(a.run_dir) + fa = final_answer(t) + + j.check("opened_wishlist", navigated_to(t, "/account/wishlist"), + f"visited={navigated_to(t, '/account/wishlist')}") + + initial = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + before = wishlist_skus(initial, EMAIL) + now = wishlist_skus(after, EMAIL) + + j.check("db_readable", before is not None and now is not None, + f"before={before} after={now}") + + if before is not None and now is not None: + j.check("named_item_removed", SKU not in now, f"wishlist after={now}") + # Only that one may be gone — removing a different item would leave the + # same count and slip past a count-only check. + removed = set(before) - set(now) + j.check("only_named_item_removed", removed == {SKU}, + f"removed={removed or 'nothing'} (expected exactly {{{SKU}}})") + j.check("nothing_added", not set(now) - set(before), + f"added={set(now)-set(before)}") + # Naming what remains requires reading the list; the account page's + # "Wishlist items N" tile would have satisfied a count question. + remaining_terms = [["katie"], ["cinnamon toast crunch", "cinnamon"], + ["organic mini sandwich", "cheddar cheese crackers"]] + named = sum(1 for terms in remaining_terms if contains_any(fa, terms)) + j.check("answer_names_remaining_items", named >= 2, + f"named {named}/3 remaining items: final={fa!r}") + j.check("did_not_claim_removed_item_remains", + not contains_any(fa, ["starbucks"]), + f"answer still lists the removed item: final={fa!r}") + else: + for name in ("named_item_removed", "only_named_item_removed", + "nothing_added", "answer_names_remaining_items", + "did_not_claim_removed_item_remains"): + j.check(name, False, "DB unavailable") + + s = last_shot(t) + if s: + ok, ev = llm_screenshot_shows(s, "a wish list that no longer lists the Starbucks ground coffee", + "the wish list after the removal") + j.check("screenshot_shows_wishlist", ok, ev, llm=True) + else: + j.check("screenshot_shows_wishlist", False, "no screenshots in run") + + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/target/verify/verify_15.py b/sites/target/verify/verify_15.py new file mode 100644 index 00000000..a79c1aeb --- /dev/null +++ b/sites/target/verify/verify_15.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for Target--15 (multi-page comparison). + +Across the Red Baron frozen pizzas, report which has the LOWEST sodium per +serving and what that value is. + +Ground truth (frozen here, never read from tasks.jsonl): + Red Baron Supreme Classic Crust 650mg <- lowest, unique + Red Baron Four Cheese Classic Crust 710mg + Red Baron Cheese Trio Brick Oven 710mg + Red Baron Pepperoni Classic Crust 790mg + Red Baron Pepperoni Brick Oven 810mg + Red Baron Four Cheese Deep Dish 1750mg + Red Baron Pepperoni Deep Dish 1950mg + +650mg is held by exactly one product, so the answer is unambiguous. Sodium is +absent from every listing page, so the values can only come from the detail +pages — requiring more than one visit is what makes this a comparison rather +than a lookup. + +An earlier version of this task asked for "the sodium of the Red Baron frozen +pizza" and graded on whether the agent asked which one. CONTRIBUTING rejects +tasks that presuppose a human in the loop, so it was re-anchored onto a +question with a single correct answer. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, last_shot, step_urls, + has_number, contains_any, resolve_db, db_unchanged_for, + llm_text_match, llm_screenshot_shows, Judge, parse_args) + +LOWEST_MG = 650 +OTHER_VALUES = [710, 790, 810, 1750, 1950] +# Every Red Baron SKU, so we can count how many detail pages were opened. +RED_BARON_SKUS = ["TGT13333997", "TGT13334000", "TGT31168521", "TGT13376389", + "TGT31168522", "TGT13374348", "TGT13374157"] + + +def main(): + a = parse_args() + j = Judge("Target--15", a.no_llm) + t = load_run(a.run_dir) + fa = final_answer(t) + + urls = " ".join(step_urls(t)) + opened = [s for s in RED_BARON_SKUS if f"/product/{s}" in urls] + # One page cannot establish a minimum across the range. + j.check("opened_multiple_product_pages", len(opened) >= 2, + f"opened {len(opened)} Red Baron product pages: {opened}") + + j.check("answer_has_lowest_value", has_number(fa, LOWEST_MG), f"final={fa!r}") + j.check("answer_names_supreme", contains_any(fa, ["supreme"]), f"final={fa!r}") + # Quoting a different variant's figure as "the lowest" is the wrong answer. + wrong = [v for v in OTHER_VALUES if has_number(fa, v) + and not has_number(fa, LOWEST_MG)] + j.check("did_not_report_a_higher_variant", not wrong, + f"answer quotes {wrong} but not {LOWEST_MG}: final={fa!r}") + + initial = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + unchanged = db_unchanged_for(initial, after, "alice.j@test.com") + j.check("read_only_task_left_db_alone", unchanged is True, + f"db_unchanged={unchanged}") + + ok, ev = llm_text_match(fa, "Red Baron Supreme Classic Crust, 650mg of sodium per serving", + "Which Red Baron frozen pizza has the lowest sodium per serving, and what is that value?") + j.check("answer_matches_ground_truth", ok, ev, llm=True) + + s = last_shot(t) + if s: + ok, ev = llm_screenshot_shows(s, "a Red Baron pizza nutrition row showing 650mg of sodium", + "the lowest-sodium Red Baron pizza's nutrition facts") + j.check("screenshot_shows_sodium", ok, ev, llm=True) + else: + j.check("screenshot_shows_sodium", False, "no screenshots in run") + + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/target/verify/verify_16.py b/sites/target/verify/verify_16.py new file mode 100644 index 00000000..f8c85b21 --- /dev/null +++ b/sites/target/verify/verify_16.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for Target--16. + +Compare percent-recommended of Ninja DualBrew GP161 vs Cuisinart 14 Cup Programmable. + +Ground truth (frozen here, never read from tasks.jsonl): + Ninja DualBrew GP161 TGT94682442 = 66%; Cuisinart 14 Cup TGT94139349 = 59%.\n Ninja is higher. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, last_shot, visited_product, navigated_to, + has_number, contains_any, contains_all, resolve_db, + db_unchanged_for, llm_text_match, llm_screenshot_shows, + Judge, parse_args) + + +def main(): + a = parse_args() + j = Judge("Target--16", a.no_llm) + t = load_run(a.run_dir) + fa = final_answer(t) + + NINJA, CUISINART = "TGT94682442", "TGT94139349" + j.check("opened_ninja_page", visited_product(t, NINJA), f"visited={visited_product(t, NINJA)}") + j.check("opened_cuisinart_page", visited_product(t, CUISINART), + f"visited={visited_product(t, CUISINART)}") + j.check("answer_has_both_percents", has_number(fa, 66) and has_number(fa, 59), + f"final={fa!r}") + j.check("answer_names_ninja_as_higher", contains_any(fa, ["ninja"]), f"final={fa!r}") + + initial = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + unchanged = db_unchanged_for(initial, after, "alice.j@test.com") + j.check("read_only_task_left_db_alone", unchanged is True, + f"db_unchanged={unchanged}") + + ok, ev = llm_text_match(fa, "Ninja DualBrew GP161 is higher at 66%, versus 59% for the Cuisinart 14 Cup", "Which coffee maker do more guests recommend, and what are both percentages?") + j.check("answer_matches_ground_truth", ok, ev, llm=True) + + s = last_shot(t) + if s: + ok, ev = llm_screenshot_shows(s, "recommendation percentages 66% and 59%", "Which coffee maker do more guests recommend, and what are both percentages?") + j.check("screenshot_shows_answer", ok, ev, llm=True) + else: + j.check("screenshot_shows_answer", False, "no screenshots in run") + + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/target/verify/verify_17.py b/sites/target/verify/verify_17.py new file mode 100644 index 00000000..7bd44207 --- /dev/null +++ b/sites/target/verify/verify_17.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for Target--17 (stateful: pickup checkout). + +Sign in as bob.c@test.com, add TWO Colgate Total Active Prevention Whitening +Toothpaste to the cart, complete checkout as a store pickup order at the +Denver Stapleton store, and report the order number and pickup slot. + +Ground truth (frozen here, never read from tasks.jsonl): + SKU TGT1012287965; pickup store slug "denver-stapleton"; the task names the + slot explicitly, so the booked window must be "9:00 AM - 11:00 AM". + +Two design notes: + +* The task places the order rather than stopping at the pickup step. Checkout + state lives in the session, so a run that stops early leaves no database + trace of which store was chosen; placing the order writes store_id onto the + order row, which makes the store binding checkable. +* The task names the pickup time instead of letting the agent choose one. + Every store offers the SAME five windows, so "whichever slot you picked" was + only weakly checkable — and an agent could book the right-looking time at the + wrong store. With the slot fixed, store and time are both exact matches. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, last_shot, visited_product, + navigated_to, norm, resolve_db, db_query, new_orders, + order_items, llm_screenshot_shows, Judge, parse_args) + +EMAIL = "bob.c@test.com" +SKU = "TGT1012287965" +STORE_SLUG = "denver-stapleton" +SLOT = "9:00 AM - 11:00 AM" + + +def main(): + a = parse_args() + j = Judge("Target--17", a.no_llm) + t = load_run(a.run_dir) + fa = final_answer(t) + + j.check("opened_product_page", visited_product(t, SKU), + f"visited /product/{SKU}={visited_product(t, SKU)}") + j.check("reached_pickup_step", navigated_to(t, "/checkout/pickup"), + f"visited={navigated_to(t, '/checkout/pickup')}") + j.check("reached_confirmation", navigated_to(t, "/checkout/confirmation"), + f"visited={navigated_to(t, '/checkout/confirmation')}") + + initial = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + created = new_orders(initial, after, EMAIL) + + j.check("db_has_new_order", bool(created), f"new orders for {EMAIL}: {created}") + + if created: + j.check("exactly_one_new_order", len(created) == 1, f"count={len(created)}") + number, _status, _total, fulfilment = created[0] + + j.check("order_is_pickup", fulfilment == "pickup", + f"fulfillment={fulfilment}") + + # The store binding is the whole point — same slot labels everywhere. + rows = db_query(after, + "SELECT s.slug, o.pickup_slot_label FROM orders o " + "LEFT JOIN stores s ON s.id = o.store_id WHERE o.order_number = ?", (number,)) + slug, slot_label = (rows[0] if rows else (None, None)) + j.check("pickup_store_is_denver_stapleton", slug == STORE_SLUG, + f"order store={slug!r} (expected {STORE_SLUG!r})") + + items = order_items(after, number) or [] + row = next((r for r in items if r[0] == SKU), None) + j.check("order_contains_product", row is not None, f"order items={items}") + j.check("quantity_is_two", bool(row) and row[2] == 2, + f"row={row} (expected quantity 2)") + + j.check("answer_reports_real_order_number", + number.lower() in (fa or "").lower(), + f"db_order={number} final={fa!r}") + # The task dictates the window, so this is an exact match rather than + # "any slot this store offers". + j.check("booked_the_requested_slot", + bool(slot_label) and norm(slot_label) == norm(SLOT), + f"order slot={slot_label!r} (task asked for {SLOT!r})") + else: + for name in ("exactly_one_new_order", "order_is_pickup", + "pickup_store_is_denver_stapleton", "order_contains_product", + "quantity_is_two", "answer_reports_real_order_number", + "booked_the_requested_slot"): + j.check(name, False, "no new order was created") + + s = last_shot(t) + if s: + ok, ev = llm_screenshot_shows(s, "a pickup order confirmation with an order number and a pickup time", + "the checkout confirmation page") + j.check("screenshot_shows_confirmation", ok, ev, llm=True) + else: + j.check("screenshot_shows_confirmation", False, "no screenshots in run") + + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/target/verify/verify_18.py b/sites/target/verify/verify_18.py new file mode 100644 index 00000000..c09347b7 --- /dev/null +++ b/sites/target/verify/verify_18.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for Target--18 (stateful: open a support request). + +Sign in as bob.c@test.com, submit a support request with the subject +"Order arrived damaged" and Email as the contact method, then report its +status. + +Ground truth (frozen here, never read from tasks.jsonl): + A new support_tickets row for bob.c@test.com with + subject = "Order arrived damaged", channel = "Email", status = "Open". + bob.c starts with one seeded ticket, so the count must grow by exactly one. + +The subject and channel are dictated by the task, which is what makes this +checkable: an agent that submits the form with its own wording has not done +what was asked, even though a ticket exists. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, last_shot, navigated_to, + contains_any, resolve_db, db_query, + llm_screenshot_shows, Judge, parse_args) + +EMAIL = "bob.c@test.com" +SUBJECT = "Order arrived damaged" +CHANNEL = "Email" + + +def tickets_for(db_path, email): + """[(subject, channel, status)] for this account, oldest first.""" + if not db_path: + return None + rows = db_query(db_path, + "SELECT t.subject, t.channel, t.status FROM support_tickets t " + "JOIN users u ON u.id = t.user_id WHERE u.email = ? ORDER BY t.id", (email,)) + return [tuple(r) for r in rows] + + +def main(): + a = parse_args() + j = Judge("Target--18", a.no_llm) + t = load_run(a.run_dir) + fa = final_answer(t) + + j.check("opened_contact_form", navigated_to(t, "/support/contact"), + f"visited={navigated_to(t, '/support/contact')}") + + initial = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + before = tickets_for(initial, EMAIL) + now = tickets_for(after, EMAIL) + + j.check("db_readable", before is not None and now is not None, + f"before={before} after={now}") + + if before is not None and now is not None: + j.check("one_new_ticket", len(now) == len(before) + 1, + f"before={len(before)} after={len(now)}") + created = [row for row in now if row not in before] + match = next((r for r in created if r[0].strip() == SUBJECT), None) + j.check("subject_matches_task", match is not None, + f"new tickets={created} (expected subject {SUBJECT!r})") + j.check("channel_is_email", bool(match) and match[1] == CHANNEL, + f"channel={match[1] if match else None} (expected {CHANNEL})") + j.check("answer_reports_status", + bool(match) and contains_any(fa, [match[2]]), + f"status={match[2] if match else None} final={fa!r}") + else: + for name in ("one_new_ticket", "subject_matches_task", + "channel_is_email", "answer_reports_status"): + j.check(name, False, "DB unavailable") + + s = last_shot(t) + if s: + ok, ev = llm_screenshot_shows(s, "a support request listed on the account", + "the account's support requests page") + j.check("screenshot_shows_request", ok, ev, llm=True) + else: + j.check("screenshot_shows_request", False, "no screenshots in run") + + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/target/verify/verify_19.py b/sites/target/verify/verify_19.py new file mode 100644 index 00000000..3b486db8 --- /dev/null +++ b/sites/target/verify/verify_19.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for Target--19 (stateful: write a product review). + +Sign in as carol.d@test.com, open the Beats Pill Wireless Bluetooth Speaker, +and post a 4-star review headlined "Great sound for the size". + +Ground truth (frozen here, never read from tasks.jsonl): + SKU TGT92595737 (Beats Pill Wireless Bluetooth Speaker). + A new reviews row for that product with rating = 4 and + title = "Great sound for the size", authored by carol.d's display name. + +Rating and headline are dictated by the task. Checking them separately matters: +the review form defaults to no rating, so an agent that types the headline but +skips the star selector produces a row that looks nearly right. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, last_shot, visited_product, + resolve_db, db_query, llm_screenshot_shows, + Judge, parse_args) + +SKU = "TGT92595737" +HEADLINE = "Great sound for the size" +RATING = 4 + + +def reviews_for(db_path, sku): + """[(title, rating, author_name)] on this product, oldest first.""" + if not db_path: + return None + rows = db_query(db_path, + "SELECT r.title, r.rating, r.author_name FROM reviews r " + "JOIN products p ON p.id = r.product_id WHERE p.sku = ? ORDER BY r.id", (sku,)) + return [tuple(r) for r in rows] + + +def main(): + a = parse_args() + j = Judge("Target--19", a.no_llm) + t = load_run(a.run_dir) + fa = final_answer(t) + + j.check("opened_product_page", visited_product(t, SKU), + f"visited /product/{SKU}={visited_product(t, SKU)}") + + initial = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + before = reviews_for(initial, SKU) + now = reviews_for(after, SKU) + + j.check("db_readable", before is not None and now is not None, + f"before={before} after={now}") + + if before is not None and now is not None: + j.check("one_new_review", len(now) == len(before) + 1, + f"before={len(before)} after={len(now)}") + created = [row for row in now if row not in before] + match = next((r for r in created if r[0].strip() == HEADLINE), None) + j.check("headline_matches_task", match is not None, + f"new reviews={created} (expected headline {HEADLINE!r})") + # The star selector starts empty; a missed selection is the common miss. + j.check("rating_is_four", bool(match) and match[1] == RATING, + f"rating={match[1] if match else None} (expected {RATING})") + j.check("existing_reviews_intact", all(r in now for r in before), + f"before={before} after={now}") + else: + for name in ("one_new_review", "headline_matches_task", + "rating_is_four", "existing_reviews_intact"): + j.check(name, False, "DB unavailable") + + s = last_shot(t) + if s: + ok, ev = llm_screenshot_shows(s, f"a review titled {HEADLINE!r} on the product page", + "the product page after the review was posted") + j.check("screenshot_shows_review", ok, ev, llm=True) + else: + j.check("screenshot_shows_review", False, "no screenshots in run") + + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/target/verify/verify_2.py b/sites/target/verify/verify_2.py new file mode 100644 index 00000000..39f5fc1e --- /dev/null +++ b/sites/target/verify/verify_2.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for Target--2. + +In the Grocery department, find Katie's Burrata Margherita Frozen Pizza and +report the sodium per serving. + +Ground truth (frozen here, never read from tasks.jsonl): + SKU TGT94764181, Nutrition Facts -> Sodium 610mg + +Checks: opened THIS product's detail page | answer states 610 mg | read-only +run left the DB untouched | screenshot visibly shows the nutrition row. +The sodium row exists only on the detail page — it is not on a search card — +so a correct answer without the navigation step is a knowledge shortcut. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, last_shot, visited_product, + has_number, contains_any, resolve_db, db_unchanged_for, + llm_text_match, llm_screenshot_shows, Judge, parse_args) + +SKU = "TGT94764181" +SODIUM_MG = 610 + + +def main(): + a = parse_args() + j = Judge("Target--2", a.no_llm) + t = load_run(a.run_dir) + fa = final_answer(t) + + j.check("opened_product_page", visited_product(t, SKU), + f"visited /product/{SKU}={visited_product(t, SKU)}") + + # 610 must appear as a number, and the answer must be about sodium/mg — + # "610" alone could be a price or a SKU fragment. + j.check("answer_sodium_value", has_number(fa, SODIUM_MG), f"final={fa!r}") + j.check("answer_mentions_sodium", contains_any(fa, ["sodium", "mg"]), + f"final={fa!r}") + + initial = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + unchanged = db_unchanged_for(initial, after, "alice.j@test.com") + j.check("read_only_task_left_db_alone", unchanged is True, + f"cart/wishlist/orders unchanged={unchanged}") + + ok, ev = llm_text_match(fa, f"{SODIUM_MG}mg of sodium per serving", + "How much sodium per serving does Katie's Burrata Margherita Frozen Pizza contain?") + j.check("answer_matches_ground_truth", ok, ev, llm=True) + + s = last_shot(t) + if s: + ok, ev = llm_screenshot_shows(s, f"Sodium {SODIUM_MG}mg", + "the sodium row in the product's nutrition facts") + j.check("screenshot_shows_sodium", ok, ev, llm=True) + else: + j.check("screenshot_shows_sodium", False, "no screenshots in run") + + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/target/verify/verify_3.py b/sites/target/verify/verify_3.py new file mode 100644 index 00000000..ed06f378 --- /dev/null +++ b/sites/target/verify/verify_3.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for Target--3. + +Compare sodium of Red Baron Pepperoni Classic Crust vs Four Cheese Classic Crust. + +Ground truth (frozen here, never read from tasks.jsonl): + Pepperoni Classic TGT13376389 = 790mg; Four Cheese Classic TGT13334000 = 710mg.\n Four Cheese has LESS sodium. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, last_shot, visited_product, navigated_to, + has_number, contains_any, contains_all, resolve_db, + db_unchanged_for, llm_text_match, llm_screenshot_shows, + Judge, parse_args) + + +def main(): + a = parse_args() + j = Judge("Target--3", a.no_llm) + t = load_run(a.run_dir) + fa = final_answer(t) + + PEPPERONI, FOURCHEESE = "TGT13376389", "TGT13334000" + j.check("opened_pepperoni_page", visited_product(t, PEPPERONI), + f"visited={visited_product(t, PEPPERONI)}") + j.check("opened_fourcheese_page", visited_product(t, FOURCHEESE), + f"visited={visited_product(t, FOURCHEESE)}") + j.check("answer_has_both_values", has_number(fa, 790) and has_number(fa, 710), + f"final={fa!r}") + # The comparison itself must be right, not just the two numbers quoted. + j.check("answer_names_four_cheese_as_lower", contains_any(fa, ["four cheese", "4 cheese"]), + f"final={fa!r}") + + initial = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + unchanged = db_unchanged_for(initial, after, "alice.j@test.com") + j.check("read_only_task_left_db_alone", unchanged is True, + f"db_unchanged={unchanged}") + + ok, ev = llm_text_match(fa, "Four Cheese Classic Crust has less sodium: 710mg vs 790mg for Pepperoni Classic Crust", "Which of the two Red Baron Classic Crust pizzas has less sodium, and what are the values?") + j.check("answer_matches_ground_truth", ok, ev, llm=True) + + s = last_shot(t) + if s: + ok, ev = llm_screenshot_shows(s, "sodium values 790mg and 710mg", "Which of the two Red Baron Classic Crust pizzas has less sodium, and what are the values?") + j.check("screenshot_shows_answer", ok, ev, llm=True) + else: + j.check("screenshot_shows_answer", False, "no screenshots in run") + + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/target/verify/verify_4.py b/sites/target/verify/verify_4.py new file mode 100644 index 00000000..76ad6dde --- /dev/null +++ b/sites/target/verify/verify_4.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for Target--4. + +Report Mr. Coffee 5 Cup Switch percent-recommended and its highest-scoring attribute. + +Ground truth (frozen here, never read from tasks.jsonl): + SKU TGT91986267, 74% would recommend.\n Secondary ratings: quality 4.1, design 4.2, ease of use 4.5, easy to clean 4.6, value 4.3\n -> highest is 'easy to clean' (4.6). +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, last_shot, visited_product, navigated_to, + has_number, contains_any, contains_all, resolve_db, + db_unchanged_for, llm_text_match, llm_screenshot_shows, + Judge, parse_args) + + +def main(): + a = parse_args() + j = Judge("Target--4", a.no_llm) + t = load_run(a.run_dir) + fa = final_answer(t) + + SKU = "TGT91986267" + j.check("opened_product_page", visited_product(t, SKU), + f"visited={visited_product(t, SKU)}") + j.check("answer_has_percent", has_number(fa, 74), f"final={fa!r}") + j.check("answer_names_easy_to_clean", contains_any(fa, ["easy to clean", "easy-to-clean"]), + f"final={fa!r}") + + initial = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + unchanged = db_unchanged_for(initial, after, "alice.j@test.com") + j.check("read_only_task_left_db_alone", unchanged is True, + f"db_unchanged={unchanged}") + + ok, ev = llm_text_match(fa, "74% would recommend; the highest rated attribute is 'easy to clean' at 4.6 out of 5", "What percent of guests recommend the Mr. Coffee 5 Cup Switch, and which attribute scored highest?") + j.check("answer_matches_ground_truth", ok, ev, llm=True) + + s = last_shot(t) + if s: + ok, ev = llm_screenshot_shows(s, "74% would recommend and an easy to clean rating", "What percent of guests recommend the Mr. Coffee 5 Cup Switch, and which attribute scored highest?") + j.check("screenshot_shows_answer", ok, ev, llm=True) + else: + j.check("screenshot_shows_answer", False, "no screenshots in run") + + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/target/verify/verify_5.py b/sites/target/verify/verify_5.py new file mode 100644 index 00000000..4dab1684 --- /dev/null +++ b/sites/target/verify/verify_5.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for Target--5. + +Report the price gap between the Sony WH-1000XM6 3-year and 2-year protection plans, and which covers accidental handling. + +Ground truth (frozen here, never read from tasks.jsonl): + SKU TGT94760871. 2-year $36.72 (no accidental), 3-year $59.67 (accidental).\n Difference = $22.95; the 3-year plan covers accidental handling. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, last_shot, visited_product, navigated_to, + has_number, contains_any, contains_all, resolve_db, + db_unchanged_for, llm_text_match, llm_screenshot_shows, + Judge, parse_args) + + +def main(): + a = parse_args() + j = Judge("Target--5", a.no_llm) + t = load_run(a.run_dir) + fa = final_answer(t) + + SKU = "TGT94760871" + j.check("opened_product_page", visited_product(t, SKU), + f"visited={visited_product(t, SKU)}") + # Accept either the delta or both plan prices stated. + delta_ok = has_number(fa, 22.95) + both_ok = has_number(fa, 36.72) and has_number(fa, 59.67) + j.check("answer_has_price_gap", delta_ok or both_ok, + f"delta={delta_ok} both_prices={both_ok} final={fa!r}") + j.check("answer_names_three_year_plan", contains_any(fa, ["3-year", "3 year", "three-year", "three year"]), + f"final={fa!r}") + + initial = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + unchanged = db_unchanged_for(initial, after, "alice.j@test.com") + j.check("read_only_task_left_db_alone", unchanged is True, + f"db_unchanged={unchanged}") + + ok, ev = llm_text_match(fa, "The 3-year plan costs $22.95 more ($59.67 vs $36.72) and it is the plan that covers accidental handling", "How much more is the 3-year protection plan than the 2-year, and which covers accidental handling?") + j.check("answer_matches_ground_truth", ok, ev, llm=True) + + s = last_shot(t) + if s: + ok, ev = llm_screenshot_shows(s, "two protection plan prices", "How much more is the 3-year protection plan than the 2-year, and which covers accidental handling?") + j.check("screenshot_shows_answer", ok, ev, llm=True) + else: + j.check("screenshot_shows_answer", False, "no screenshots in run") + + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/target/verify/verify_6.py b/sites/target/verify/verify_6.py new file mode 100644 index 00000000..261a43dc --- /dev/null +++ b/sites/target/verify/verify_6.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for Target--6 (search & filter). + +Browse Pets, filter to pickup-eligible items, sort price low to high, and +report the name and price of the first result. + +Ground truth (frozen here, never read from tasks.jsonl): + Cheapest pickup-eligible product in Pets = Wet Dog Food - 12.5oz - Kindfull + (SKU TGT82688642) at $2.19. + +The check requires the filtered listing URL, not just any Pets page: the +answer is only correct under BOTH the pickup filter and the price sort, so +an agent that eyeballed the unsorted first page should not pass. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, last_shot, step_urls, + navigated_to, has_number, contains_any, resolve_db, + db_unchanged_for, llm_text_match, llm_screenshot_shows, + Judge, parse_args) + +PRICE = 2.19 + + +def main(): + a = parse_args() + j = Judge("Target--6", a.no_llm) + t = load_run(a.run_dir) + fa = final_answer(t) + + j.check("browsed_pets_category", navigated_to(t, "/category/pets"), + f"visited={navigated_to(t, '/category/pets')}") + + urls = [u.lower() for u in step_urls(t)] + applied = any("pickup=1" in u or "availability=pickup" in u for u in urls) + sorted_ = any("sort=price-asc" in u for u in urls) + j.check("applied_pickup_filter", applied, + f"no pickup filter in any URL: {urls[:6]}") + j.check("applied_price_sort", sorted_, + f"no sort=price-asc in any URL: {urls[:6]}") + + j.check("answer_has_price", has_number(fa, PRICE), f"final={fa!r}") + j.check("answer_names_product", contains_any(fa, ["wet dog food", "kindfull"]), + f"final={fa!r}") + + initial = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + unchanged = db_unchanged_for(initial, after, "alice.j@test.com") + j.check("read_only_task_left_db_alone", unchanged is True, + f"db_unchanged={unchanged}") + + ok, ev = llm_text_match(fa, "Wet Dog Food - 12.5oz - Kindfull, $2.19", + "What is the cheapest pickup-eligible product in the Pets department?") + j.check("answer_matches_ground_truth", ok, ev, llm=True) + + s = last_shot(t) + if s: + ok, ev = llm_screenshot_shows(s, "a Pets listing sorted by price with a $2.19 item first", + "the filtered and sorted Pets listing") + j.check("screenshot_shows_listing", ok, ev, llm=True) + else: + j.check("screenshot_shows_listing", False, "no screenshots in run") + + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/target/verify/verify_7.py b/sites/target/verify/verify_7.py new file mode 100644 index 00000000..e774a300 --- /dev/null +++ b/sites/target/verify/verify_7.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for Target--7. + +Compare Red Baron Supreme Classic Crust vs Pepperoni Deep Dish Personal by sodium. + +Ground truth (frozen here, never read from tasks.jsonl): + Supreme Classic TGT13333997 = 650mg; Pepperoni Deep Dish Personal TGT13374157 = 1950mg.\n Supreme has less, by 1300mg. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, last_shot, visited_product, navigated_to, + has_number, contains_any, contains_all, resolve_db, + db_unchanged_for, llm_text_match, llm_screenshot_shows, + Judge, parse_args) + + +def main(): + a = parse_args() + j = Judge("Target--7", a.no_llm) + t = load_run(a.run_dir) + fa = final_answer(t) + + SUPREME, DEEPDISH = "TGT13333997", "TGT13374157" + j.check("opened_supreme_page", visited_product(t, SUPREME), + f"visited={visited_product(t, SUPREME)}") + j.check("opened_deepdish_page", visited_product(t, DEEPDISH), + f"visited={visited_product(t, DEEPDISH)}") + j.check("answer_names_supreme_as_lower", contains_any(fa, ["supreme"]), f"final={fa!r}") + # Either the difference or both raw values proves the arithmetic was done. + diff_ok = has_number(fa, 1300) + both_ok = has_number(fa, 650) and has_number(fa, 1950) + j.check("answer_has_difference", diff_ok or both_ok, + f"diff={diff_ok} both={both_ok} final={fa!r}") + + initial = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + unchanged = db_unchanged_for(initial, after, "alice.j@test.com") + j.check("read_only_task_left_db_alone", unchanged is True, + f"db_unchanged={unchanged}") + + ok, ev = llm_text_match(fa, "Red Baron Supreme Classic Crust has less sodium: 650mg vs 1950mg, a difference of 1300mg", "Which pizza has less sodium and by how many milligrams?") + j.check("answer_matches_ground_truth", ok, ev, llm=True) + + s = last_shot(t) + if s: + ok, ev = llm_screenshot_shows(s, "sodium values 650mg and 1950mg", "Which pizza has less sodium and by how many milligrams?") + j.check("screenshot_shows_answer", ok, ev, llm=True) + else: + j.check("screenshot_shows_answer", False, "no screenshots in run") + + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/target/verify/verify_8.py b/sites/target/verify/verify_8.py new file mode 100644 index 00000000..e9713810 --- /dev/null +++ b/sites/target/verify/verify_8.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for Target--8. + +Sign in as david.k and report the order number and total of the Processing order. + +Ground truth (frozen here, never read from tasks.jsonl): + david.k@test.com has exactly one Processing order: TGT-240013, total $32.61. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, last_shot, visited_product, navigated_to, + has_number, contains_any, contains_all, resolve_db, + db_unchanged_for, llm_text_match, llm_screenshot_shows, + Judge, parse_args) + + +def main(): + a = parse_args() + j = Judge("Target--8", a.no_llm) + t = load_run(a.run_dir) + fa = final_answer(t) + + j.check("opened_order_history", navigated_to(t, "/account/orders"), + f"visited={navigated_to(t, '/account/orders')}") + j.check("answer_has_order_number", contains_any(fa, ["TGT-240013"]), f"final={fa!r}") + j.check("answer_has_total", has_number(fa, 32.61), f"final={fa!r}") + + initial = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + unchanged = db_unchanged_for(initial, after, "david.k@test.com") + j.check("read_only_task_left_db_alone", unchanged is True, + f"db_unchanged={unchanged}") + + ok, ev = llm_text_match(fa, "Order TGT-240013, total $32.61", "Which order is still Processing, and what is its total?") + j.check("answer_matches_ground_truth", ok, ev, llm=True) + + s = last_shot(t) + if s: + ok, ev = llm_screenshot_shows(s, "order TGT-240013 with a $32.61 total", "Which order is still Processing, and what is its total?") + j.check("screenshot_shows_answer", ok, ev, llm=True) + else: + j.check("screenshot_shows_answer", False, "no screenshots in run") + + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/target/verify/verify_9.py b/sites/target/verify/verify_9.py new file mode 100644 index 00000000..17880013 --- /dev/null +++ b/sites/target/verify/verify_9.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for Target--9. + +Sign in as david.k and report the Target Circle points balance. + +Ground truth (frozen here, never read from tasks.jsonl): + david.k@test.com reward points balance = 2185. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, last_shot, visited_product, navigated_to, + has_number, contains_any, contains_all, resolve_db, + db_unchanged_for, llm_text_match, llm_screenshot_shows, + Judge, parse_args) + + +def main(): + a = parse_args() + j = Judge("Target--9", a.no_llm) + t = load_run(a.run_dir) + fa = final_answer(t) + + j.check("opened_rewards_page", navigated_to(t, "/account/rewards"), + f"visited={navigated_to(t, '/account/rewards')}") + j.check("answer_has_points", has_number(fa, 2185), f"final={fa!r}") + + initial = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + unchanged = db_unchanged_for(initial, after, "david.k@test.com") + j.check("read_only_task_left_db_alone", unchanged is True, + f"db_unchanged={unchanged}") + + ok, ev = llm_text_match(fa, "2185 points", "How many Target Circle reward points are in the account?") + j.check("answer_matches_ground_truth", ok, ev, llm=True) + + s = last_shot(t) + if s: + ok, ev = llm_screenshot_shows(s, "a points balance of 2185", "How many Target Circle reward points are in the account?") + j.check("screenshot_shows_answer", ok, ev, llm=True) + else: + j.check("screenshot_shows_answer", False, "no screenshots in run") + + j.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/target/verify/verify_lib.py b/sites/target/verify/verify_lib.py new file mode 100644 index 00000000..fc4f9501 --- /dev/null +++ b/sites/target/verify/verify_lib.py @@ -0,0 +1,423 @@ +#!/usr/bin/env python3 +"""verify_lib.py — shared deterministic + LLM utilities for Target task verification. + +Philosophy: DETERMINISTIC FIRST. + 1. Trajectory navigation check (anti knowledge-shortcut): the agent MUST have + opened the relevant on-site page. A correct answer with no matching + navigation is a memory-recall shortcut = FAIL. This matters more on a + retail mirror than elsewhere: a model may "know" that Red Baron pizza + exists, but it cannot know THIS mirror's prices or nutrition rows. + 2. Answer check: exact / numeric / token-containment against frozen ground + truth hardcoded in each verify_N.py — never read from tasks.jsonl. + 3. DB after-state check (stateful tasks): query the SQLite instance DB + directly. Cart contents, wishlist rows and placed orders are the + strongest deterministic signal that a flow actually completed. + 4. LLM utilities are used ONLY where exact matching is brittle, and are + ALWAYS anchored on ground truth: the model verifies *presence* of given + content, it never supplies knowledge. One call each. + +Input signature (per task): + --run_dir DIR agent trajectory dir: trajectory.json + screenshots/step_NNN.png + --initial_db PATH initial-state SQLite DB (default: fetched instance_seed from container) + --after_db PATH after-state SQLite DB (default: fetched live instance DB from container) + --container NAME docker container to fetch DBs from (default: $WH_CONTAINER or wh-review) + --no_llm skip LLM-based checks (run deterministic-only) +Output: JSON {task_id, pass, reason, evidence[]} to stdout; exit 0 on PASS, 1 on FAIL. +""" +import base64, json, os, re, sqlite3, subprocess, sys, tempfile, urllib.request +from pathlib import Path +from dataclasses import dataclass + +SITE = "target" + +BENCHMARK_PASSWORD = "TestPass123!" + + +# ---------------------------------------------------------------- trajectory +def load_run(run_dir): + d = Path(run_dir) + traj = json.loads((d / "trajectory.json").read_text()) + traj["_run_dir"] = d + traj["_shots"] = {p.name: p for p in sorted((d / "screenshots").glob("step_*.png"))} + return traj + + +def step_urls(traj): + return [s.get("url", "") for s in traj.get("steps", [])] + + +def navigated_to(traj, substr, times=1): + """Deterministic: at least `times` trajectory steps have a URL containing substr.""" + return sum(1 for u in step_urls(traj) if substr in u) >= times + + +def navigated_any(traj, substrs): + return any(navigated_to(traj, s) for s in substrs) + + +def visited_product(traj, sku): + """The agent opened this specific product's detail page.""" + return navigated_to(traj, f"/product/{sku}") + + +def searched_for(traj, *terms): + """A /search (or filtered listing) URL carried every one of these terms. + + Query strings are URL-encoded, so compare on a normalised form where + '+', '%20' and literal spaces are all equivalent. + """ + def norm_url(u): + return u.replace("+", " ").replace("%20", " ").lower() + urls = [norm_url(u) for u in step_urls(traj) if "q=" in u] + return any(all(t.lower() in u for t in terms) for u in urls) + + +def final_answer(traj): + return (traj.get("final_answer") or "").strip() + + +def _shot(traj, name): + if not name: + return None + p = traj["_shots"].get(Path(name).name) + return p if (p and p.exists()) else None + + +def shot_after_url(traj, substr): + """screenshot_after path of the first step whose URL contains substr.""" + for s in traj.get("steps", []): + if substr in s.get("url", ""): + p = _shot(traj, s.get("screenshot_after")) + if p: + return p + return None + + +def last_shot(traj): + for s in reversed(traj.get("steps", [])): + p = _shot(traj, s.get("screenshot_after")) or _shot(traj, s.get("screenshot_before")) + if p: + return p + shots = sorted(traj["_shots"].values()) + return shots[-1] if shots else None + + +# ---------------------------------------------------------------- deterministic answer match +def norm(s): + return re.sub(r"\s+", " ", (s or "").strip()).casefold() + + +def answer_equals(final, expected): + return norm(final) == norm(expected) + + +def contains_all(final, tokens): + f = norm(final) + return all(norm(t) in f for t in tokens) + + +def contains_any(final, tokens): + f = norm(final) + return any(norm(t) in f for t in tokens) + + +def numbers_in(text): + """Every number in the text, as floats. '$12.99' -> [12.99]; '1,240' -> [1240.0].""" + out = [] + for m in re.finditer(r"\d[\d,]*(?:\.\d+)?", text or ""): + try: + out.append(float(m.group(0).replace(",", ""))) + except ValueError: + pass + return out + + +def has_number(text, value, tol=0.001): + """The answer states this number. Tolerant of $, commas and trailing units.""" + return any(abs(n - value) <= tol for n in numbers_in(text)) + + +def has_money(text, amount): + """Currency match that tolerates '12.99' / '$12.99' / '12.99 USD'. + + Tolerance is deliberately below one cent: $249.98 is a different answer + from $249.99, and only float representation noise should be absorbed. + """ + return has_number(text, round(float(amount), 2), tol=0.005) + + +# ---------------------------------------------------------------- DB state +def fetch_db(container, kind): + """kind: 'instance' (after-state) or 'instance_seed' (initial-state).""" + src = f"{container}:/opt/WebSyn/{SITE}/{kind}/{SITE}.db" + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + r = subprocess.run(["docker", "cp", src, path], capture_output=True, text=True) + if r.returncode != 0: + try: + os.unlink(path) + except OSError: + pass + raise RuntimeError(f"docker cp {src} failed: {r.stderr.strip()}") + return path + + +def resolve_db(arg, container, kind): + if arg: + return arg + try: + return fetch_db(container, kind) + except Exception: + return None # caller treats None as "unavailable" and FAILs that check + + +def db_query(db_path, sql, params=()): + con = sqlite3.connect(db_path) + try: + return con.execute(sql, params).fetchall() + finally: + con.close() + + +# --- product-side reads (ground truth is hardcoded per task; these are for +# --- cross-checking that the mirror still holds what the verifier expects) +def product_row(db_path, sku): + if not db_path: + return None + rows = db_query(db_path, + "SELECT sku, name, price, list_price, rating, review_count, percent_recommended " + "FROM products WHERE sku=?", (sku,)) + if not rows: + return None + keys = ("sku", "name", "price", "list_price", "rating", "review_count", + "percent_recommended") + return dict(zip(keys, rows[0])) + + +# --- user-side after-state reads +def cart_for(db_path, email): + """[(sku, name, quantity)] currently in this user's cart, ordered by sku.""" + if not db_path: + return None + rows = db_query(db_path, + "SELECT p.sku, p.name, c.quantity FROM cart_items c " + "JOIN users u ON u.id = c.user_id JOIN products p ON p.id = c.product_id " + "WHERE u.email = ? ORDER BY p.sku", (email,)) + return [tuple(r) for r in rows] + + +def cart_skus(db_path, email): + rows = cart_for(db_path, email) + return None if rows is None else [r[0] for r in rows] + + +def wishlist_skus(db_path, email): + if not db_path: + return None + rows = db_query(db_path, + "SELECT p.sku FROM wishlist_items w " + "JOIN users u ON u.id = w.user_id JOIN products p ON p.id = w.product_id " + "WHERE u.email = ? ORDER BY p.sku", (email,)) + return [r[0] for r in rows] + + +def orders_for(db_path, email): + """[(order_number, status, total, fulfillment_method)] oldest first.""" + if not db_path: + return None + rows = db_query(db_path, + "SELECT o.order_number, o.status, o.total, o.fulfillment_method FROM orders o " + "JOIN users u ON u.id = o.user_id WHERE u.email = ? ORDER BY o.id", (email,)) + return [tuple(r) for r in rows] + + +def new_orders(initial_db, after_db, email): + """Orders that exist in the after-state but not the initial state. + + This is how a 'place an order' task is proven: the flow must have created + a row, not merely reached a confirmation-looking page. + """ + before = orders_for(initial_db, email) + after = orders_for(after_db, email) + if before is None or after is None: + return None + seen = {o[0] for o in before} + return [o for o in after if o[0] not in seen] + + +def order_items(db_path, order_number): + """[(sku, item_name, quantity)] on a given order.""" + if not db_path: + return None + rows = db_query(db_path, + "SELECT p.sku, oi.item_name, oi.quantity FROM order_items oi " + "JOIN orders o ON o.id = oi.order_id LEFT JOIN products p ON p.id = oi.product_id " + "WHERE o.order_number = ? ORDER BY oi.id", (order_number,)) + return [tuple(r) for r in rows] + + +def user_exists(db_path, name=None, email=None): + if not db_path: + return None + rows = db_query(db_path, "SELECT full_name, email FROM users") + return any((name is None or r[0] == name) and (email is None or r[1] == email) + for r in rows) + + +def db_unchanged_for(initial_db, after_db, email): + """True when this user's cart, wishlist and orders are all untouched. + + Used by read-only tasks: an answer-only task should not have mutated state, + and a no-op run should not accidentally satisfy a stateful task. + """ + parts = (cart_for, wishlist_skus, orders_for) + for fn in parts: + a, b = fn(initial_db, email), fn(after_db, email) + if a is None or b is None or a != b: + return False + return True + + +# ---------------------------------------------------------------- shared LLM utilities (anchored) +# Unified LLM config, same env vars as agent.py / eval_judge.py: +# OPENAI_API_KEY, OPENAI_BASE_URL, JUDGE_MODEL +import simpleArgParser as sap + +# When --no_llm is set (via Judge), the llm_* helpers short-circuit so verifiers +# that call them directly (before j.check(llm=True)) still make ZERO LLM calls. +_NO_LLM = False + + +def _llm_config(): + key = os.environ.get("OPENAI_API_KEY", "") + base = os.environ.get("OPENAI_BASE_URL", "") + model = os.environ.get("JUDGE_MODEL", "") + return key, base, model + + +def _chat(messages, max_tokens=1024): + """One LLM call against the configured OpenAI-compatible endpoint.""" + if _NO_LLM: + return None + key, base, model = _llm_config() + if not (key and base and model): + return None # no LLM configured -> callers treat as non-PASS + payload = {"model": model, "messages": messages, + "max_tokens": max_tokens, "temperature": 1.0} + req = urllib.request.Request(base, + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json", + "Authorization": f"Bearer {key}"}) + try: + data = json.loads(urllib.request.urlopen(req, timeout=180).read()) + except Exception: + return None # caller treats None as a non-PASS; never raises + try: + return data["choices"][0]["message"]["content"] + except Exception: + return None + + +def _verdict(out): + if not out: + return False, "" + s = out.strip() + return s.upper().startswith("PASS"), s + + +def llm_text_match(agent_answer, ground_truth, question): + """One LLM call: does agent_answer answer question AND stay consistent with + the frozen ground truth? The model gets the ground truth as an anchor and is + told NOT to use its own knowledge.""" + if _NO_LLM: + return False, "[skipped: --no_llm]" + out = _chat([{"role": "user", "content": + f"You are a STRICT binary grader.\nQuestion: {question}\n" + f"Ground-truth answer (ANCHOR — judge against THIS, never use your own knowledge): {ground_truth}\n" + f"Agent's answer: {agent_answer}\n" + f"Decide PASS or FAIL ignoring case/punctuation/word order/surrounding prose. " + f"PASS only if the agent's answer is consistent with the ground truth AND actually answers the question. " + f"Line 1: PASS or FAIL. Line 2: one-sentence reason."}]) + return _verdict(out) + + +def llm_screenshot_shows(shot_path, must_show, question=""): + """One vision LLM call: does this screenshot visibly render the expected content?""" + if _NO_LLM: + return False, "[skipped: --no_llm]" + b64 = base64.b64encode(Path(shot_path).read_bytes()).decode() + out = _chat([{"role": "user", "content": [ + {"type": "text", "text": + f"You are a STRICT binary grader. Only what is VISIBLY rendered in this screenshot counts.\n" + f"Question the page should answer: {question}\n" + f"Expected content to verify PRESENCE of: {must_show}\n" + f"PASS only if the expected content (or a semantically equivalent on-screen answer) is visibly shown. " + f"Do NOT use prior knowledge — judge only the rendered pixels.\n" + f"Line 1: PASS or FAIL. Line 2: quote the visible evidence."}, + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}]}]) + return _verdict(out) + + +def llm_asked_for_clarification(agent_answer, options, what): + """Disambiguation tasks: the agent should ask WHICH one, not silently pick. + + Anchored on the actual ambiguous options so the grader can tell a genuine + clarifying question from a confident wrong guess. + """ + if _NO_LLM: + return False, "[skipped: --no_llm]" + out = _chat([{"role": "user", "content": + f"You are a STRICT binary grader.\n" + f"The user asked the agent to: {what}\n" + f"This is AMBIGUOUS because these options all match: {options}\n" + f"Agent's final response: {agent_answer}\n" + f"PASS only if the agent recognised the ambiguity and asked the user which one they meant " + f"(or explicitly listed the options and requested a choice). " + f"FAIL if the agent silently picked one, or acted without asking.\n" + f"Line 1: PASS or FAIL. Line 2: one-sentence reason."}]) + return _verdict(out) + + +# ---------------------------------------------------------------- judge harness + CLI +class Judge: + def __init__(self, task_id, no_llm=False): + global _NO_LLM + _NO_LLM = bool(no_llm) # gate the llm_* helpers at the source + self.task_id = task_id + self.no_llm = no_llm + self.ok = True + self.reason = "" + self.evidence = [] + + def check(self, name, cond, evidence="", llm=False): + if llm and self.no_llm: + self.evidence.append(f"[SKIP] {name} (--no-llm)") + return True + if cond: + self.evidence.append(f"[PASS] {name}: {evidence}") + else: + self.ok = False + if not self.reason: + self.reason = name # record the FIRST failing check + self.evidence.append(f"[FAIL] {name}: {evidence}") + return bool(cond) + + def emit(self): + print(json.dumps({"task_id": self.task_id, "pass": self.ok, + "reason": self.reason, "evidence": self.evidence}, indent=2)) + sys.exit(0 if self.ok else 1) + + +def parse_args(): + @dataclass + class VerifyArgs: + run_dir: str = "" + initial_db: str = "" + after_db: str = "" + container: str = os.environ.get("WH_CONTAINER", "wh-review") + no_llm: bool = False + + def post_process(self): + if not self.run_dir: + raise SystemExit("--run_dir is required") + return sap.parse_args(VerifyArgs) diff --git a/websyn_start.sh b/websyn_start.sh index 4d690d78..a07868d0 100644 --- a/websyn_start.sh +++ b/websyn_start.sh @@ -5,7 +5,7 @@ set -e SITES=(allrecipes amazon apple arxiv bbc_news booking github google_flights google_map google_search huggingface wolfram_alpha - cambridge_dictionary coursera espn merriam_webster) + cambridge_dictionary coursera espn merriam_webster target) BASE_PORT=40000 PID_DIR=/tmp/websyn_pids mkdir -p "$PID_DIR" @@ -17,7 +17,7 @@ for d in "${SITES[@]}"; do cp -a "/opt/WebSyn/$d/instance_seed" "/opt/WebSyn/$d/instance" done -echo "[WebSyn] Starting 16 sites on ports ${BASE_PORT}-$((BASE_PORT + 15))..." +echo "[WebSyn] Starting ${#SITES[@]} sites on ports ${BASE_PORT}-$((BASE_PORT + ${#SITES[@]} - 1))..." for i in "${!SITES[@]}"; do site="${SITES[$i]}" port=$((BASE_PORT + i)) From faeec4866ee8fdf61a5a745e641c7506aa178fea Mon Sep 17 00:00:00 2001 From: raibows Date: Sat, 5 Sep 2026 19:58:12 -0700 Subject: [PATCH 2/2] chore: pin merged Target asset revision --- .assets-revision | 2 +- scripts/fetch_assets.sh | 18 +++++------------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/.assets-revision b/.assets-revision index 3e2d8cb8..35808cfa 100644 --- a/.assets-revision +++ b/.assets-revision @@ -5,4 +5,4 @@ # is a git revision (branch name like `main`, a tag, or a specific commit # sha). Override at runtime with the ASSETS_REVISION env var. repo: ChilleD/WebHarbor -revision: ebf73305228b7ba86d9fbcb737b8ccbee2b8956a +revision: 8d8e4069588ef55594622fee1ab9fa51c2011d07 diff --git a/scripts/fetch_assets.sh b/scripts/fetch_assets.sh index 3a2cf278..613a5315 100755 --- a/scripts/fetch_assets.sh +++ b/scripts/fetch_assets.sh @@ -22,7 +22,6 @@ REPO=$(awk '/^repo:/ {print $2}' .assets-revision) REVISION="${ASSETS_REVISION:-$(awk '/^revision:/ {print $2}' .assets-revision)}" ONLY_SITE="${1:-}" CACHE_DIR="sites/.cache/tarballs" -TARGET_ASSETS_REVISION="${TARGET_ASSETS_REVISION:-674d5c19e2d337c6e45482f48bd7a0e2cfbd6216}" if ! command -v hf >/dev/null 2>&1; then echo "fetch_assets: 'hf' CLI not found. Install with: pip install -U \"huggingface_hub[cli]\"" >&2 @@ -34,21 +33,14 @@ echo "[fetch] huggingface.co/datasets/$REPO @ $REVISION -> sites/" if [[ -n "$ONLY_SITE" ]]; then INCLUDE="$ONLY_SITE.tar.gz" - DOWNLOAD_REVISION="$REVISION" - if [[ "$ONLY_SITE" == "target" ]]; then - DOWNLOAD_REVISION="$TARGET_ASSETS_REVISION" - fi - echo "[fetch] scope: $ONLY_SITE only @ $DOWNLOAD_REVISION" - hf download "$REPO" --repo-type dataset --revision "$DOWNLOAD_REVISION" \ - --include "$INCLUDE" --local-dir "$CACHE_DIR" + echo "[fetch] scope: $ONLY_SITE only" else - hf download "$REPO" --repo-type dataset --revision "$REVISION" \ - --include "*.tar.gz" --local-dir "$CACHE_DIR" - echo "[fetch] target asset override @ $TARGET_ASSETS_REVISION" - hf download "$REPO" --repo-type dataset --revision "$TARGET_ASSETS_REVISION" \ - --include "target.tar.gz" --local-dir "$CACHE_DIR" + INCLUDE="*.tar.gz" fi +hf download "$REPO" --repo-type dataset --revision "$REVISION" \ + --include "$INCLUDE" --local-dir "$CACHE_DIR" + shopt -s nullglob extracted=0 for tarball in "$CACHE_DIR"/*.tar.gz; do