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/AGENTS.md b/AGENTS.md index 22be9948..8f6e45b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ A coding agent (Claude Code, Cursor, Aider, Codex, ...) is reading this. Read on ## What it is -18 Flask mirror websites (Amazon, GitHub, BBC News, ...) packaged into one Docker image, plus a control plane on `:8101` for resetting per-site state. Used as a deterministic offline environment for web-agent benchmarks. ~3 GB image. +19 Flask mirror websites (Amazon, GitHub, BBC News, ...) packaged into one Docker image, plus a control plane on `:8101` for resetting per-site state. Used as a deterministic offline environment for web-agent benchmarks. ~3 GB image. Two repos: - **code** (this one) — Flask apps, control plane, scripts. @@ -48,17 +48,17 @@ Inside the image, sites live at `/opt/WebSyn//`. The path predates the ren # fresh clone ./scripts/fetch_assets.sh # pulls assets from HF ./scripts/build.sh # docker build -t webharbor:dev . -docker run -d -p 8101:8101 -p 40000-40017:40000-40017 webharbor:dev +docker run -d -p 8101:8101 -p 40000-40018:40000-40018 webharbor:dev ``` Or use the published image directly: ```bash -docker run -d -p 8101:8101 -p 40000-40017:40000-40017 \ +docker run -d -p 8101:8101 -p 40000-40018:40000-40018 \ battalion7244/webharbor:latest ``` -Sites are on `40000`-`40017` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: +Sites are on `40000`-`40018` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: | Method | Path | Purpose | |--------|---------------------|-------------------------------------------| @@ -136,13 +136,13 @@ python3 -m py_compile sites//app.py # 3. run on alt ports (don't collide with anything you already have running) docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41017:40000-40017 webharbor:dev + -p 8201:8101 -p 41000-41018:40000-40018 webharbor:dev # 4. control plane healthy, all sites alive curl -s http://localhost:8201/health | python3 -m json.tool | head # 5. every site renders 200 -for p in $(seq 41000 41017); do +for p in $(seq 41000 41018); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done diff --git a/CLAUDE.md b/CLAUDE.md index 028f2c1e..3f6b490e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,4 +16,4 @@ The full agent guide is loaded above via `@AGENTS.md`. The notes below apply onl ## Existing containers -If a container is already running on `:8101` / `:40000-40017`, treat it as the user's working environment — don't `docker stop` or `docker rm` it without explicit confirmation. Spin up your test container under a different name on alt ports (`:8201`, `:41000-41017`). +If a container is already running on `:8101` / `:40000-40018`, treat it as the user's working environment — don't `docker stop` or `docker rm` it without explicit confirmation. Spin up your test container under a different name on alt ports (`:8201`, `:41000-41018`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 58f90f49..07a7248b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ git clone https://github.com//webharbor && cd webharbor ./scripts/fetch_assets.sh # pull current assets ./scripts/new_site.py mywebsite # OR edit an existing site ./scripts/build.sh && docker run -d --rm \ - -p 8101:8101 -p 40000-40017:40000-40017 webharbor:dev + -p 8101:8101 -p 40000-40018:40000-40018 webharbor:dev # iterate locally... ./scripts/extract_assets.sh ../webharbor-static-pr/ # split assets out diff --git a/Dockerfile b/Dockerfile index 046e2cc8..24105b6a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # WebHarbor — slim, self-contained image. -# 18 Flask mirror sites + control plane on :8101. +# 19 Flask mirror sites + control plane on :8101. FROM python:3.12-slim-bookworm @@ -31,14 +31,15 @@ COPY sites/ /opt/WebSyn/ # IKEA's seed is reproducibly materialized from the tracked source catalog so code-only content fixes do not require an asset-repository write. Product images still come from the pinned asset bundle. RUN cd /opt/WebSyn/ikea && PYTHONHASHSEED=0 python seed_data.py && rm -rf instance -# Apply tracked, idempotent Phys.org data corrections to the pinned seed asset. +# Apply tracked, idempotent Phys.org and Target data corrections to their seed assets. RUN cd /opt/WebSyn/phys_org && PYTHONHASHSEED=0 python migrate_seed.py && rm -rf instance +RUN cd /opt/WebSyn/target && PYTHONHASHSEED=0 python migrate_seed.py && rm -rf instance COPY websyn_start.sh /opt/websyn_start.sh 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-40017 +EXPOSE 8101 40000-40018 CMD ["/opt/websyn_start.sh"] diff --git a/README.md b/README.md index a4645e19..acf7bf0e 100644 --- a/README.md +++ b/README.md @@ -36,17 +36,17 @@ WebHarbor takes a different approach. We leverage coding agent (e.g., Claude Cod - **Deep features unlocked** — carts, checkouts, accounts, all fully testable - **Evolving** — harder tasks drive richer mirrors; the environment grows with agents - **RL-ready** — sub-second database resets between rollouts -- **Community-driven** — 18 sites today, scaling to 100+ together +- **Community-driven** — 19 sites today, scaling to 100+ together ## 🚀 Quickstart One command to run all web environments: ```bash -docker run -p 8101:8101 -p 40000-40017:40000-40017 battalion7244/webharbor:latest +docker run -p 8101:8101 -p 40000-40018:40000-40018 battalion7244/webharbor:latest ``` -Then point your agent at `http://localhost:40000` through `http://localhost:40017` to explore 18 local mirrors of webvoyager sites: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, ESPN, Merriam-Webster, IKEA, and Phys.org`. +Then point your agent at `http://localhost:40000` through `http://localhost:40018` to explore 19 local mirrors of webvoyager sites: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, ESPN, Merriam-Webster, IKEA, Phys.org, and Target`. For sub-second reset between rollouts, expose the control plane and call `/reset/`: diff --git a/agent_demo/README.md b/agent_demo/README.md index c6f93833..2b775eac 100644 --- a/agent_demo/README.md +++ b/agent_demo/README.md @@ -19,7 +19,7 @@ export OPENAI_BASE_URL=https://api.openai.com/v1 # or your Azure / vLLM endpoi ## Run a task -WebHarbor must already be running locally (`docker run -p 8101:8101 -p 40000-40017:40000-40017 battalion7244/webharbor:latest`). +WebHarbor must already be running locally (`docker run -p 8101:8101 -p 40000-40018:40000-40018 battalion7244/webharbor:latest`). Run a single task from a site's `tasks.jsonl`: diff --git a/control_server.py b/control_server.py index 70ca9002..af39b036 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', 'ikea', 'phys_org', + 'coursera', 'espn', 'merriam_webster', 'ikea', 'phys_org', 'target', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' diff --git a/scripts/fetch_assets.sh b/scripts/fetch_assets.sh index 9d06120c..613a5315 100755 --- a/scripts/fetch_assets.sh +++ b/scripts/fetch_assets.sh @@ -48,9 +48,9 @@ for tarball in "$CACHE_DIR"/*.tar.gz; do if [[ -n "$ONLY_SITE" && "$site" != "$ONLY_SITE" ]]; then continue; fi echo "[fetch] extracting $site" if tar --version 2>/dev/null | grep -q 'GNU tar'; then - tar --warning=no-unknown-keyword -xzf "$tarball" -C sites/ + tar --warning=no-unknown-keyword --exclude='._*' -xzf "$tarball" -C sites/ else - tar -xzf "$tarball" -C sites/ + COPYFILE_DISABLE=1 tar --exclude='._*' -xzf "$tarball" -C sites/ fi migrator="sites/$site/migrate_seed.py" database="sites/$site/instance_seed/$site.db" 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..c84b979a --- /dev/null +++ b/sites/target/app.py @@ -0,0 +1,1955 @@ +"""Target demo mirror for WebHarbor.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import secrets +import shutil +from datetime import datetime +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +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 flask_wtf.csrf import CSRFProtect +from sqlalchemy import or_ + + +SITE_SLUG = "target" +SITE_NAME = "Target" +SITE_PORT = 40018 +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"] = os.environ.get("TARGET_SECRET_KEY") or secrets.token_hex(32) +app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{RUNTIME_DB_PATH}" +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + +db = SQLAlchemy(app) +csrf = CSRFProtect(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("-") + + +def safe_next(target: str | None, fallback: str) -> str: + if not target or "\\" in target: + return fallback + parsed = urlparse(target) + if parsed.scheme or parsed.netloc or not target.startswith("/") or target.startswith("//"): + return fallback + return target + + +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, + "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 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(), + ) + + +INFO_PAGES = { + "about": ("About this mirror", "This local Target mirror supports deterministic web-agent evaluation and does not represent Target Corporation."), + "careers": ("Careers", "Career listings are outside this offline benchmark. Use the catalog, stores, account, and support features available in this mirror."), + "investors": ("Investor relations", "Investor materials are outside this offline benchmark. No live corporate or market information is provided."), +} + + +@app.route("/info/") +def info_page(page_slug: str): + title, body = INFO_PAGES.get(page_slug) or abort(404) + return render_template("info_page.html", title=title, body=body) + + +@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) + 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() + ) + candidates = Product.query.filter( + Product.category_id == product.category_id, + Product.id != product.id, + ).limit(200).all() + generic_words = {"and", "for", "the", "with", "from", "target", "wireless"} + target_tokens = {token for token in search_tokens(product.name) if token not in generic_words} + related_products = sorted( + candidates, + key=lambda candidate: ( + candidate.brand_id == product.brand_id, + len(target_tokens & {token for token in search_tokens(candidate.name) if token not in generic_words}), + candidate.rating, + candidate.review_count, + ), + reverse=True, + )[:4] + 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("/help") +@app.route("/support") +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() + 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() + ) + 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") + return redirect(safe_next(request.args.get("next"), url_for("account"))) + 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", methods=["POST"]) +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(): + order_count = Order.query.filter_by(user_id=current_user.id).count() + open_ticket_count = SupportTicket.query.filter_by( + user_id=current_user.id, status="Open" + ).count() + return render_template( + "account.html", + order_count=order_count, + open_ticket_count=open_ticket_count, + 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, + ) + ) + previous_count = max(0, product.review_count) + product.rating = round( + ((product.rating * previous_count) + rating) / (previous_count + 1), 1 + ) + product.review_count = previous_count + 1 + 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(safe_next(request.form.get("next") or request.referrer, + 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(safe_next(request.form.get("next") or request.referrer, + 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") + if fulfillment_method not in {"delivery", "pickup"}: + fulfillment_method = "delivery" + if fulfillment_method == "delivery" and not product.delivery_eligible: + flash("This product is not available for delivery.", "warning") + return redirect(safe_next(request.form.get("next"), url_for("product_page", sku=product.sku))) + if fulfillment_method == "pickup" and not product.pickup_eligible: + flash("This product is not available for store pickup.", "warning") + return redirect(safe_next(request.form.get("next"), url_for("product_page", sku=product.sku))) + 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") + protection_plan = None + if protection_plan_id: + protection_plan = ProtectionPlan.query.filter_by( + id=int(protection_plan_id), product_id=product.id + ).first() if protection_plan_id.isdigit() else None + if protection_plan is None: + flash("Choose a protection plan offered for this product.", "warning") + return redirect(safe_next(request.form.get("next"), url_for("product_page", sku=product.sku))) + + 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 = protection_plan.id if protection_plan else None + db.session.commit() + flash(f"Added {product.name} to your cart.", "success") + return redirect(safe_next(request.form.get("next"), 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() + try: + quantity = int(request.form.get("quantity", cart_item.quantity) or cart_item.quantity) + except ValueError: + flash("Choose a valid cart quantity.", "warning") + return redirect(url_for("cart_page")) + 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, + can_deliver=all(item.product.delivery_eligible for item in cart_items), + can_pickup=all(item.product.pickup_eligible for item in cart_items), + ) + + +@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": + submitted = { + "mode": "delivery", + "delivery_option_id": request.form.get("delivery_option_id", "").strip(), + "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().upper(), + "shipping_zip": request.form.get("shipping_zip", "").strip(), + } + errors = [] + if len(submitted["shipping_name"]) < 2: + errors.append("Enter the recipient name.") + if len(submitted["shipping_street"]) < 5: + errors.append("Enter a complete street address.") + if len(submitted["shipping_city"]) < 2: + errors.append("Enter a city.") + if not re.fullmatch(r"[A-Z]{2}", submitted["shipping_state"]): + errors.append("Enter a two-letter state code.") + if not re.fullmatch(r"\d{5}(?:-\d{4})?", submitted["shipping_zip"]): + errors.append("Enter a valid ZIP code.") + option = db.session.get(DeliveryOption, int(submitted["delivery_option_id"])) if submitted["delivery_option_id"].isdigit() else None + if option is None: + errors.append("Choose a delivery option.") + unavailable = [item.product.name for item in cart_items if not item.product.delivery_eligible] + if unavailable: + errors.append("Remove products that are unavailable for delivery: " + ", ".join(unavailable)) + if errors: + for message in errors: + flash(message, "error") + checkout.update(submitted) + return render_template( + "checkout_shipping.html", + cart_items=cart_items, + delivery_options=delivery_options, + checkout=checkout, + ), 400 + checkout.update(submitted) + 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.") + slot = resolve_pickup_slot(store.id, slot_window) if store and slot_window in windows else None + if slot is None or slot.available_capacity < 1: + errors.append("Choose an available pickup time.") + if store: + for item in cart_items: + inventory = StoreInventory.query.filter_by( + store_id=store.id, product_id=item.product_id + ).first() + if not item.product.pickup_eligible or inventory is None or inventory.quantity < item.quantity: + errors.append(f"{item.product.name} is unavailable for pickup at {store.name}.") + + if errors: + for message in errors: + flash(message, "error") + else: + checkout.update({"mode": "pickup", "store_id": store_id, + "slot_window": slot_window, "slot_id": slot.id}) + 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")) + if summary["mode"] == "delivery": + required_shipping = ("shipping_name", "shipping_street", "shipping_city", "shipping_state", "shipping_zip") + if any(not checkout.get(field) for field in required_shipping): + flash("Complete the shipping address before placing the order.", "warning") + return redirect(url_for("checkout_shipping")) + if summary["mode"] == "pickup": + slot = summary["pickup_slot"] + if slot is None or slot.available_capacity < 1: + flash("The selected pickup time is no longer available.", "warning") + return redirect(url_for("checkout_pickup")) + for item in cart_items: + inventory = StoreInventory.query.filter_by( + store_id=summary["store"].id, product_id=item.product_id + ).first() + if not item.product.pickup_eligible or inventory is None or inventory.quantity < item.quantity: + flash(f"{item.product.name} is no longer available for this pickup order.", "warning") + return redirect(url_for("checkout_pickup")) + + 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="Processing" 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=checkout.get("slot_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.", + ) + ) + + if summary["mode"] == "pickup": + summary["pickup_slot"].available_capacity -= 1 + for item in cart_items: + inventory = StoreInventory.query.filter_by( + store_id=summary["store"].id, product_id=item.product_id + ).one() + inventory.quantity -= item.quantity + + 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/migrate_seed.py b/sites/target/migrate_seed.py new file mode 100644 index 00000000..05ba0332 --- /dev/null +++ b/sites/target/migrate_seed.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Apply tracked Target corrections to the downloaded seed database.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sqlite3 +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +DEFAULT_DB = BASE_DIR / "instance_seed" / "target.db" +DEEP_DISH_SKUS = {"TGT13374157", "TGT13374348"} + + +def fulfillment_eligible(sku: str, method: str, unavailable_modulus: int) -> bool: + if (sku, method) in {("TGT94640332", "delivery"), ("TGT85566854", "pickup")}: + return True + digest = hashlib.sha256(f"{sku}:{method}".encode()).digest() + return int.from_bytes(digest[:4], "big") % unavailable_modulus != 0 + + +def normalize_specs(sku: str, raw_specs: str) -> str: + specs = json.loads(raw_specs or "[]") + if sku not in DEEP_DISH_SKUS: + return json.dumps(specs, ensure_ascii=False) + for section in specs: + if section.get("title") == "Nutrition Facts": + section["title"] = "Nutrition Facts — entire 2-pizza package" + if section.get("title") == "Nutrition Facts — entire 2-pizza package": + for item in section.get("items", []): + if item.get("label") == "Sodium": + item["label"] = "Sodium — package total" + return json.dumps(specs, ensure_ascii=False) + + +def migrate_database(database_path: str | Path = DEFAULT_DB) -> int: + connection = sqlite3.connect(database_path) + connection.row_factory = sqlite3.Row + changed = 0 + try: + products = connection.execute( + "SELECT id,sku,specs_json,pickup_eligible,delivery_eligible,price,list_price,deal_badge FROM products" + ).fetchall() + for product in products: + pickup = int(fulfillment_eligible(product["sku"], "pickup", 5)) + delivery = int(fulfillment_eligible(product["sku"], "delivery", 11)) + specs = normalize_specs(product["sku"], product["specs_json"]) + discount = round((product["list_price"] - product["price"]) / product["list_price"] * 100) if product["list_price"] > product["price"] > 0 else 0 + deal_badge = product["deal_badge"] if discount >= 1 else "" + corrected = (specs, pickup, delivery, deal_badge) + current = ( + json.dumps(json.loads(product["specs_json"] or "[]"), ensure_ascii=False), + int(product["pickup_eligible"]), + int(product["delivery_eligible"]), + product["deal_badge"], + ) + if corrected != current: + connection.execute( + "UPDATE products SET specs_json=?,pickup_eligible=?,delivery_eligible=?,deal_badge=? WHERE id=?", + (*corrected, product["id"]), + ) + changed += 1 + + bob_id = connection.execute( + "SELECT id FROM users WHERE lower(email)=lower('bob.c@test.com')" + ).fetchone() + if bob_id: + deleted = connection.execute( + "DELETE FROM cart_items WHERE user_id=?", (bob_id["id"],) + ).rowcount + changed += deleted + + cart_rows = connection.execute( + "SELECT c.id,c.fulfillment_method,c.store_id,p.pickup_eligible,p.delivery_eligible " + "FROM cart_items c JOIN products p ON p.id=c.product_id" + ).fetchall() + for row in cart_rows: + if not row["pickup_eligible"] and not row["delivery_eligible"]: + connection.execute("DELETE FROM cart_items WHERE id=?", (row["id"],)) + changed += 1 + continue + method = row["fulfillment_method"] + if method == "pickup" and not row["pickup_eligible"]: + method = "delivery" if row["delivery_eligible"] else "pickup" + if method == "delivery" and not row["delivery_eligible"]: + method = "pickup" if row["pickup_eligible"] else "delivery" + store_id = row["store_id"] if method == "pickup" else None + if method != row["fulfillment_method"] or store_id != row["store_id"]: + connection.execute( + "UPDATE cart_items SET fulfillment_method=?,store_id=? WHERE id=?", + (method, store_id, row["id"]), + ) + changed += 1 + + if changed: + connection.commit() + return changed + finally: + connection.close() + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("database", nargs="?", default=str(DEFAULT_DB)) + args = parser.parse_args() + changed = migrate_database(args.database) + noun = "row" if changed == 1 else "rows" + print(f"Target seed migration complete: {changed} {noun} changed.") + + +if __name__ == "__main__": + main() diff --git a/sites/target/requirements.txt b/sites/target/requirements.txt new file mode 100644 index 00000000..4351b673 --- /dev/null +++ b/sites/target/requirements.txt @@ -0,0 +1,5 @@ +Flask +Flask-Login +Flask-SQLAlchemy +Flask-WTF + diff --git a/sites/target/seed_data.py b/sites/target/seed_data.py new file mode 100644 index 00000000..b2f4388d --- /dev/null +++ b/sites/target/seed_data.py @@ -0,0 +1,722 @@ +"""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 hashlib +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 _fulfillment_eligible(sku: str, method: str, unavailable_modulus: int) -> bool: + if (sku, method) in {("TGT94640332", "delivery"), ("TGT85566854", "pickup")}: + return True + digest = hashlib.sha256(f"{sku}:{method}".encode()).digest() + return int.from_bytes(digest[:4], "big") % unavailable_modulus != 0 + + +def _normalize_specs(sku: str, specs: list[dict]) -> list[dict]: + if sku not in {"TGT13374157", "TGT13374348"}: + return specs + normalized = json.loads(json.dumps(specs)) + for section in normalized: + if section.get("title") == "Nutrition Facts": + section["title"] = "Nutrition Facts — entire 2-pizza package" + for item in section.get("items", []): + if item.get("label") == "Sodium": + item["label"] = "Sodium — package total" + return normalized + + +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 = _normalize_specs(item["sku"], 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=_fulfillment_eligible(item["sku"], "pickup", 5), + delivery_eligible=_fulfillment_eligible(item["sku"], "delivery", 11), + 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): + if user.email != "bob.c@test.com": + for slot in range(2): + product_index = (u_index * 97 + slot * 41) % len(products) + product = products[product_index] + while not (product.pickup_eligible or product.delivery_eligible): + product_index = (product_index + 1) % len(products) + product = products[product_index] + wants_pickup = slot % 2 == 1 and product.pickup_eligible + method = "pickup" if wants_pickup else "delivery" + if method == "delivery" and not product.delivery_eligible: + method = "pickup" + db.session.add( + CartItem( + user_id=user.id, + product_id=product.id, + quantity=1 + slot, + fulfillment_method=method, + store_id=stores[(u_index + slot) % len(stores)].id if method == "pickup" else None, + 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..e485a2cb --- /dev/null +++ b/sites/target/static/css/main.css @@ -0,0 +1,1509 @@ +: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: 1px solid var(--tgt-blue); + border-radius: 14px; + background: var(--tgt-blue); + color: #fff; + 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: #fff; + color: var(--tgt-blue); + border-color: var(--tgt-blue); +} + +.button--ghost { + background: var(--tgt-blue-soft); + border: 1px solid var(--tgt-border); + color: var(--tgt-blue); +} + +.button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.header-actions { + display: flex; + align-items: center; + gap: 14px; + font-weight: 700; + flex-wrap: wrap; +} + +.header-action-form { margin: 0; } + +.header-action-form button { + border: 0; + padding: 0; + background: transparent; + color: var(--tgt-blue); + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.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, +.category-nav__root[open] .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; + flex-wrap: wrap; + justify-content: space-between; + gap: 12px; + align-items: center; +} + +.product-grid { + display: grid; + gap: 18px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.product-grid, +.product-card, +.catalog-layout > *, +.product-layout > *, +.account-layout > *, +.checkout-layout > * { + min-width: 0; +} + +.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: contain; + 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__image-wrap, +.product-card__body, +.product-card__actions { + width: 100%; + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; +} + +.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 minmax(0, 1fr); +} + +.dual-grid { + grid-template-columns: repeat(2, minmax(0, 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-disclosure > summary { + display: none; + cursor: pointer; + font-weight: 800; +} + +.filter-disclosure:not([open]) > .filter-form { + display: grid; +} + +.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-scroll-hint { + color: var(--tgt-slate); + font-size: 0.9rem; +} + +.table-wrap { + overflow-x: auto; + border: 1px solid var(--tgt-border); + 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 minmax(0, 1fr); + gap: 16px; + padding: 16px; + align-items: center; +} + +.store-card--text { + grid-template-columns: 1fr; +} + +.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. */ +.mini-product { + min-width: 0; + flex: 1 1 auto; +} + +.mini-product img, +.cart-row img { + width: 96px; + height: 96px; + flex: 0 0 96px; + object-fit: contain; + border-radius: 12px; + background: #fff; +} + +.mini-product > div, +.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 a { + color: #fff; +} + +.support-actions, +.account-link-grid { + display: flex; + flex-wrap: wrap; + gap: 14px; + margin-bottom: 20px; +} + +.account-link-grid .account-link-card { + flex: 1 1 280px; + color: var(--tgt-ink); +} + +.delivery-address { + display: grid; + gap: 4px; + margin: 12px 0; + padding: 12px; + border-radius: 12px; + background: var(--tgt-surface-alt); +} + +.site-footer ul { + margin: 0; + padding-left: 18px; +} + +@media (max-width: 1100px) { + .hero__grid, + .catalog-layout, + .dual-grid, + .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; + } + + .site-header { + position: static; + backdrop-filter: none; + } + + .site-header__top, + .site-header__nav { + gap: 10px; + padding: 10px 0; + } + + .logo-badge { + width: 86px; + height: 66px; + } + + .logo-badge__bullseye { + width: 34px; + height: 34px; + } + + .category-nav > a { + display: none; + } + + .mega-menu { + position: static; + min-width: 0; + width: calc(100vw - 18px); + max-height: 65vh; + overflow-y: auto; + grid-template-columns: 1fr; + padding: 16px; + } + + .filter-disclosure > summary { + display: block; + } + + .filter-disclosure:not([open]) > .filter-form { + display: none; + } + + .checkout-steps { + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 5px; + } + + .checkout-step { + min-width: 0; + padding: 7px 3px; + flex-direction: column; + gap: 4px; + text-align: center; + overflow-wrap: anywhere; + } + + .checkout-step span { + width: 24px; + height: 24px; + } + + .checkout-step strong { + font-size: 0.63rem; + } + + .site-search { + grid-template-columns: 1fr; + } + + .header-actions { + width: 100%; + justify-content: flex-start; + } + + .cart-row, + .wishlist-row, + .support-list__item { + align-items: stretch; + flex-direction: column; + } + + .cart-row__actions { + width: 100%; + margin-left: 0; + } + + .cart-row__actions form, + .wishlist-row .inline-actions, + .wishlist-row .inline-actions form { + width: 100%; + } + + .wishlist-row .inline-actions .button, + .cart-row__actions .button { + width: 100%; + } + + .rating-breakdown__row { + grid-template-columns: minmax(0, 1fr) auto; + } + + .rating-breakdown__bar { + grid-column: 1 / -1; + grid-row: 2; + } + + .product-card__image { + height: auto; + aspect-ratio: 1 / 1; + } + + .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..29c2b0e8 --- /dev/null +++ b/sites/target/static/js/main.js @@ -0,0 +1,7 @@ +document.documentElement.classList.add("js"); + +if (window.matchMedia("(max-width: 760px)").matches) { + document.querySelectorAll(".filter-disclosure[open]").forEach((details) => { + details.removeAttribute("open"); + }); +} diff --git a/sites/target/tasks.jsonl b/sites/target/tasks.jsonl new file mode 100644 index 00000000..3ebcaa5e --- /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:40018/","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:40018/","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:40018/","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":"Search for 'Red Baron', open both the Red Baron Pepperoni Classic Crust and Red Baron Four Cheese Classic Crust product pages, and report which has less sodium per serving together with both sodium values.","web":"http://localhost:40018/","upstream_url":"https://www.target.com/","verifier_path":"sites/target/verify/verify_3.py","judge_rubric":"FACT CHECKPOINTS: (1) The trajectory MUST search for Red Baron before opening both named product pages. (2) The answer MUST bind each sodium-per-serving value to the correct pizza and identify the lower one. (3) Both pages and both values are required. FAIL if navigation is missing or out of order, either value is wrong or unbound, the winner is wrong, or 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:40018/","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":"Browse Electronics, filter the brand to Sony, open the Sony WH-1000XM6 Wireless Noise-Canceling Headphones, and compare its two protection plans. How much more does the 3-year plan cost, and which plan covers accidental handling?","web":"http://localhost:40018/","upstream_url":"https://www.target.com/","verifier_path":"sites/target/verify/verify_5.py","judge_rubric":"FACT CHECKPOINTS: (1) The trajectory MUST browse Electronics with the Sony brand filter before opening the named product. (2) The answer MUST report either the exact plan-price difference or both prices and bind accidental handling to the correct plan. FAIL if the filtered browse flow or detail visit is missing, the price comparison is wrong, coverage is assigned to the wrong plan, or the answer is empty."} +{"web_name":"Target","id":"Target--6","ques":"Browse Pets, filter the brand to Boots & Barkley, enable Deals only, filter to Pickup eligible, and sort by price from low to high. Report the name and price of the first product listed.","web":"http://localhost:40018/","upstream_url":"https://www.target.com/","verifier_path":"sites/target/verify/verify_6.py","judge_rubric":"FACT CHECKPOINTS: (1) One Pets listing URL MUST carry the Boots & Barkley brand, Deals-only filter, Pickup-eligible filter, and low-to-high price sort together. (2) The answer MUST report the first product name and price from that exact result. FAIL if any filter is absent or split across URLs, the first result is wrong, or the answer is empty."} +{"web_name":"Target","id":"Target--7","ques":"Use the comparison flow to compare Red Baron Supreme Classic Crust with Red Baron Pepperoni Brick Oven. Report which has less sodium per serving and by how many milligrams.","web":"http://localhost:40018/","upstream_url":"https://www.target.com/","verifier_path":"sites/target/verify/verify_7.py","judge_rubric":"FACT CHECKPOINTS: (1) The trajectory MUST add both named pizzas through visible controls and open Compare. (2) The answer MUST bind both sodium values or their exact difference to the correct products and identify the lower one. FAIL if Compare is not used, either product is absent, the comparison is wrong, or 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:40018/","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!, open the Target Circle rewards dashboard, and report the current points balance.","web":"http://localhost:40018/","upstream_url":"https://www.target.com/","verifier_path":"sites/target/verify/verify_9.py","judge_rubric":"FACT CHECKPOINTS: (1) The trajectory MUST submit David's credentials and open the rewards dashboard. (2) The answer MUST report the displayed points balance. (3) The complete database must remain unchanged. FAIL if login or rewards navigation is missing, another account is used, state changes, the balance is wrong, or 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:40018/","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 'Tide Ultra Oxi', add the matching HE Deep Cleaning Concentrated Liquid Laundry Detergent to your cart, and complete checkout as a delivery order shipped to 123 Main St, Denver, CO 80202. Report the confirmation order number.","web":"http://localhost:40018/","upstream_url":"https://www.target.com/","verifier_path":"sites/target/verify/verify_11.py","judge_rubric":"FACT CHECKPOINTS: (1) The trajectory MUST submit Bob's credentials, perform the Tide Ultra Oxi search, open the matching product, add it, complete every delivery checkout step, and reach confirmation. (2) The after-state MUST equal the initial state plus exactly one Bob delivery order containing one target item at the exact requested address, with Bob's cart cleared and only expected reward/order/payment changes. (3) The answer MUST match the new order number. FAIL on any wrong or extra item, address, account, state change, navigation omission, or empty answer."} +{"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:40018/","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:40018/","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:40018/","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":"Search for 'Red Baron'. Excluding the two Personal Deep Dish products, open each remaining Red Baron frozen pizza and determine which has the lowest sodium per serving. Report the pizza and value.","web":"http://localhost:40018/","upstream_url":"https://www.target.com/","verifier_path":"sites/target/verify/verify_15.py","judge_rubric":"FACT CHECKPOINTS: (1) The trajectory MUST search for Red Baron and open all five non-Personal-Deep-Dish Red Baron product pages. (2) The answer MUST identify the lowest per-serving sodium value and bind it to the correct product. (3) The complete database must remain unchanged. FAIL if any required product is not opened, a Personal Deep Dish package total is treated as per-serving data, the answer is wrong or empty, or state changes."} +{"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:40018/","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 Colgate Optic White Advanced Whitening Toothpastes (Sparkling White, 3.2oz) to your cart, then complete checkout as a store pickup order at Denver Stapleton for Tomorrow 9:00 AM - 11:00 AM. Report the confirmation order number.","web":"http://localhost:40018/","upstream_url":"https://www.target.com/","verifier_path":"sites/target/verify/verify_17.py","judge_rubric":"FACT CHECKPOINTS: (1) The trajectory MUST submit Bob's credentials, open the exact Colgate product, add quantity two, select Denver Stapleton and the exact Tomorrow window, complete payment/review, and reach confirmation. (2) The after-state MUST contain exactly one new Bob pickup order with only that SKU at quantity two, exact store and full slot label; cart, inventory, slot capacity, rewards, payment, and order changes must be exact. (3) The answer MUST match the new order number. FAIL on wrong product, quantity, store, time, account, state delta, navigation, or empty answer."} +{"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:40018/","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:40018/","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..db000099 --- /dev/null +++ b/sites/target/templates/_product_cards.html @@ -0,0 +1,45 @@ +
+ {% for product in products %} +
+ + {{ product.name }} + {% if product.deal_badge and product.discount_percent() > 0 %} + {{ 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.discount_percent() > 0 %} + {{ 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..aa02ef0a --- /dev/null +++ b/sites/target/templates/account.html @@ -0,0 +1,45 @@ +{% 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..858c8fac --- /dev/null +++ b/sites/target/templates/account_edit.html @@ -0,0 +1,53 @@ +{% 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..5ae77de0 --- /dev/null +++ b/sites/target/templates/base.html @@ -0,0 +1,136 @@ + + + + + + {% 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..9a7b6394 --- /dev/null +++ b/sites/target/templates/cart.html @@ -0,0 +1,66 @@ +{% 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..395517b7 --- /dev/null +++ b/sites/target/templates/checkout_mode.html @@ -0,0 +1,53 @@ +{% 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 %} +
+ {% if can_deliver %} + +

Ship it

+

Fast delivery to your address

+
+ {% endif %} + {% if can_pickup %} + +

Store pickup

+

Pick up from a nearby store

+
+ {% endif %} +
+ {% if not can_deliver or not can_pickup %}

Unavailable fulfillment methods are hidden for the current cart.

{% endif %} + {% endif %} +
+ +
+
+{% endblock %} + + diff --git a/sites/target/templates/checkout_payment.html b/sites/target/templates/checkout_payment.html new file mode 100644 index 00000000..157a27b5 --- /dev/null +++ b/sites/target/templates/checkout_payment.html @@ -0,0 +1,30 @@ +{% 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..837f5d86 --- /dev/null +++ b/sites/target/templates/checkout_pickup.html @@ -0,0 +1,46 @@ +{% 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..4877f4f7 --- /dev/null +++ b/sites/target/templates/checkout_review.html @@ -0,0 +1,54 @@ +{% 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..3dfe775d --- /dev/null +++ b/sites/target/templates/checkout_shipping.html @@ -0,0 +1,53 @@ +{% 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..6d633835 --- /dev/null +++ b/sites/target/templates/compare.html @@ -0,0 +1,80 @@ +{% 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 %} +

Scroll horizontally to compare every product column.

+
+ + + + + {% 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/info_page.html b/sites/target/templates/info_page.html new file mode 100644 index 00000000..b1a01224 --- /dev/null +++ b/sites/target/templates/info_page.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block title %}{{ title }} | Target{% endblock %} +{% block content %} +
+
+

Information

+

{{ title }}

+

{{ body }}

+ Return home +
+
+{% endblock %} diff --git a/sites/target/templates/login.html b/sites/target/templates/login.html new file mode 100644 index 00000000..6bd20bac --- /dev/null +++ b/sites/target/templates/login.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}Sign in | Target{% endblock %} +{% block content %} +
+
+

Sign in

+

Sign in to your Target account

+
+ + + + +
+

Use the account credentials supplied by your assigned task.

+
+
+

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..1f0c0329 --- /dev/null +++ b/sites/target/templates/order_detail.html @@ -0,0 +1,61 @@ +{% 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..fe96292d --- /dev/null +++ b/sites/target/templates/order_lookup.html @@ -0,0 +1,30 @@ +{% 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..27b923f5 --- /dev/null +++ b/sites/target/templates/product_detail.html @@ -0,0 +1,238 @@ +{% 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.discount_percent() > 0 %} + {{ product.list_price|currency }} + Save {{ product.discount_percent() }}% + {% endif %} +
+

{{ product.short_description }}

+
+ {{ product.availability_status }} + {% if product.pickup_eligible %}Store pickup{% endif %} + {% if product.delivery_eligible %}Delivery{% endif %} +
+
+
+ + + + {% if product.delivery_eligible or product.pickup_eligible %} + + {% endif %} + {% if product.protection_plans %} + + {% endif %} + + +
+
+ + + +
+ {% if current_user.is_authenticated %} +
+ + + +
+ {% endif %} +
+
+ +
+ +
+
+
+
+

Tech specs

+

What to compare

+
+
+ {% if product.highlights() %} +
+

Product highlights

+
    + {% for bullet in product.highlights() %} + {% if bullet|trim != product.short_description|trim %}
  • {{ bullet }}
  • {% endif %} + {% endfor %} +
+
+ {% endif %} + {% for section in product.specs() %} +
+

{{ section['title'] }}

+
+ {% for item in section['items'] %} +
{{ item['label'] }}
+
{{ item['value'] }}
+ {% endfor %} +
+
+ {% endfor %} +
+ {% if product.protection_plans %} +
+
+
+

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 %} +
+
+ {% endif %} +
+ +
+
+
+
+

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..5db71a45 --- /dev/null +++ b/sites/target/templates/products.html @@ -0,0 +1,96 @@ +{% extends "base.html" %} +{% block title %}{{ page_title }} | Target{% endblock %} +{% block content %} +
+
+
+

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

+

{{ page_title }}

+

{{ page_description }}

+
+
{{ pagination.total if pagination else 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..db50573e --- /dev/null +++ b/sites/target/templates/register.html @@ -0,0 +1,55 @@ +{% 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..b130020a --- /dev/null +++ b/sites/target/templates/search.html @@ -0,0 +1,132 @@ +{% 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..386b88e3 --- /dev/null +++ b/sites/target/templates/store_detail.html @@ -0,0 +1,69 @@ +{% 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 %} +
+

Pickup and store services

+
    + {% for service in store.services() %} +
  • {{ service }}
  • + {% 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..07b2901e --- /dev/null +++ b/sites/target/templates/stores.html @@ -0,0 +1,30 @@ +{% 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..f83f6ff9 --- /dev/null +++ b/sites/target/templates/support.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block title %}Support | Target{% endblock %} +{% block content %} +
+
+
+

Support center

+

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

+
+ +
+
+ {% if current_user.is_authenticated %} + Contact us + View support requests + {% else %} + Sign in to contact us + {% endif %} +
+
+ 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..892b8a1c --- /dev/null +++ b/sites/target/templates/support_article.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}{{ article.title }} | Target{% endblock %} +{% block content %} +
+
+

{{ article.topic }}

+

{{ article.title }}

+ {% 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..a12278ba --- /dev/null +++ b/sites/target/templates/support_contact.html @@ -0,0 +1,34 @@ +{% 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..d921f030 --- /dev/null +++ b/sites/target/templates/wishlist.html @@ -0,0 +1,50 @@ +{% 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/test_environment_quality.py b/sites/target/verify/test_environment_quality.py new file mode 100644 index 00000000..46364562 --- /dev/null +++ b/sites/target/verify/test_environment_quality.py @@ -0,0 +1,111 @@ +"""Regression checks for Target review findings.""" + +from __future__ import annotations + +import hashlib +import json +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +SITE_DIR = Path(__file__).resolve().parents[1] +REPO_ROOT = SITE_DIR.parents[1] +SEED_DB = SITE_DIR / "instance_seed" / "target.db" + + +def connection(path: Path = SEED_DB) -> sqlite3.Connection: + db = sqlite3.connect(f"file:{path}?mode=ro", uri=True) + db.row_factory = sqlite3.Row + return db + + +class EnvironmentQualityTests(unittest.TestCase): + def test_site_registration_and_tasks_use_port_40018(self) -> None: + startup = (REPO_ROOT / "websyn_start.sh").read_text(encoding="utf-8") + self.assertIn("ikea phys_org target", startup) + rows = [json.loads(line) for line in (SITE_DIR / "tasks.jsonl").read_text(encoding="utf-8").splitlines()] + self.assertEqual(20, len(rows)) + for index, row in enumerate(rows): + self.assertEqual(f"Target--{index}", row["id"]) + self.assertEqual("http://localhost:40018/", row["web"]) + self.assertEqual(f"sites/target/verify/verify_{index}.py", row["verifier_path"]) + + def test_seed_corrections_are_present(self) -> None: + db = connection() + try: + bob_cart = db.execute("SELECT COUNT(*) FROM cart_items c JOIN users u ON u.id=c.user_id WHERE u.email='bob.c@test.com'").fetchone()[0] + self.assertEqual(0, bob_cart) + target = db.execute("SELECT p.pickup_eligible,si.quantity FROM products p JOIN store_inventory si ON si.product_id=p.id JOIN stores s ON s.id=si.store_id WHERE p.sku='TGT85566854' AND s.slug='denver-stapleton'").fetchone() + self.assertEqual((1, 5), tuple(target)) + for sku in ("TGT13374157", "TGT13374348"): + specs = json.loads(db.execute("SELECT specs_json FROM products WHERE sku=?", (sku,)).fetchone()[0]) + section = next(section for section in specs if section["title"] == "Nutrition Facts — entire 2-pizza package") + sodium = next(item for item in section["items"] if item["label"] == "Sodium — package total") + self.assertIn(sodium["value"], {"1950mg", "1750mg"}) + finally: + db.close() + + def test_task_6_filters_change_the_first_result(self) -> None: + db = connection() + try: + base = "FROM products p JOIN categories c ON c.id=p.category_id JOIN brands b ON b.id=p.brand_id WHERE c.slug='pets' AND b.slug='boots-barkley' AND p.list_price>p.price" + unfiltered = db.execute(f"SELECT p.sku {base} ORDER BY p.price,p.rating DESC LIMIT 1").fetchone()[0] + filtered = db.execute(f"SELECT p.sku {base} AND p.pickup_eligible=1 ORDER BY p.price,p.rating DESC LIMIT 1").fetchone()[0] + finally: + db.close() + self.assertNotEqual(unfiltered, filtered) + self.assertEqual("TGT90310046", filtered) + + def test_migration_is_idempotent(self) -> None: + with tempfile.TemporaryDirectory(prefix="target-migration-") as temp_dir: + database = Path(temp_dir) / "target.db" + shutil.copy2(SEED_DB, database) + db = sqlite3.connect(database) + try: + db.execute("UPDATE products SET pickup_eligible=0,delivery_eligible=0 WHERE sku='TGT85566854'") + db.execute("INSERT INTO cart_items(id,user_id,product_id,quantity,fulfillment_method,created_at) SELECT 999999,u.id,p.id,1,'delivery','2026-04-01' FROM users u,products p WHERE u.email='bob.c@test.com' AND p.sku='TGT85566854'") + db.commit() + finally: + db.close() + command = [sys.executable, str(SITE_DIR / "migrate_seed.py"), str(database)] + first = subprocess.run(command, check=True, capture_output=True, text=True) + first_hash = hashlib.sha256(database.read_bytes()).hexdigest() + second = subprocess.run(command, check=True, capture_output=True, text=True) + second_hash = hashlib.sha256(database.read_bytes()).hexdigest() + self.assertNotIn("0 rows changed", first.stdout) + self.assertIn("0 rows changed", second.stdout) + self.assertEqual(first_hash, second_hash) + + def test_post_forms_have_csrf_tokens(self) -> None: + missing = [] + for template in (SITE_DIR / "templates").glob("*.html"): + lines = template.read_text(encoding="utf-8").splitlines() + for index, line in enumerate(lines): + if " None: + templates = "\n".join(path.read_text(encoding="utf-8") for path in (SITE_DIR / "templates").glob("*.html")) + self.assertNotIn('href="#"', templates) + self.assertNotIn("alice.j@test.com /", templates) + account = (SITE_DIR / "templates/account.html").read_text(encoding="utf-8") + self.assertNotIn("recent_orders", account) + self.assertNotIn("wishlist_preview", account) + stores = (SITE_DIR / "templates/stores.html").read_text(encoding="utf-8") + self.assertNotIn("store.address", stores) + self.assertNotIn("store.amenities", stores) + + def test_no_appledouble_files_are_extracted(self) -> None: + entries = [path for path in SITE_DIR.rglob("*") if path.name.startswith("._")] + self.assertEqual([], entries) + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/target/verify/test_verifiers.py b/sites/target/verify/test_verifiers.py new file mode 100644 index 00000000..68d89a50 --- /dev/null +++ b/sites/target/verify/test_verifiers.py @@ -0,0 +1,211 @@ +"""Positive and adversarial tests for every Target verifier.""" + +from __future__ import annotations + +import json +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +VERIFY_DIR = Path(__file__).resolve().parent +SEED_DB = VERIFY_DIR.parent / "instance_seed" / "target.db" +BASE = "http://localhost:40018" +PASSWORD = "TestPass123!" + +SKUS = { + 1: "TGT91151386", 2: "TGT94764181", 4: "TGT91986267", 5: "TGT94760871", + 10: "TGT91151386", 11: "TGT94640332", 12: "TGT1012287965", + 17: "TGT85566854", 19: "TGT92595737", +} + + +def url(path: str) -> str: + return BASE + path + + +def navigate(path: str) -> dict: + return {"url": url(path), "action": "navigate", "params": {}} + + +def click(path: str, destination: str) -> dict: + return {"url": url(path), "url_after": url(destination), "action": "click", "params": {}} + + +def enter(path: str, text: str, action: str = "input") -> dict: + return {"url": url(path), "action": action, "params": {"text" if action != "select" else "option": text}} + + +def transition(source: str, destination: str) -> list[dict]: + return [click(source, destination), navigate(destination)] + + +def login_steps(email: str) -> list[dict]: + return [navigate("/login"), enter("/login", email), enter("/login", PASSWORD), click("/login", "/account"), navigate("/account")] + + +def next_id(connection: sqlite3.Connection, table: str) -> int: + return connection.execute(f"SELECT COALESCE(MAX(id),0)+1 FROM {table}").fetchone()[0] + + +class VerifierTests(unittest.TestCase): + def run_verifier(self, task: int, steps: list[dict], answer: str, mutate=None, task_id: str | None = None) -> tuple[int, dict]: + with tempfile.TemporaryDirectory(prefix=f"target-verify-{task}-") as temp_dir: + root = Path(temp_dir); initial = root / "initial.db"; after = root / "after.db"; run = root / "run"; run.mkdir() + shutil.copy2(SEED_DB, initial); shutil.copy2(SEED_DB, after) + if mutate: + connection = sqlite3.connect(after) + try: + mutate(connection); connection.commit() + finally: + connection.close() + trajectory = {"task_id": task_id or f"Target--{task}", "start_url": url("/"), "steps": steps, "final_url": steps[-1].get("url_after", steps[-1].get("url")) if steps else url("/"), "final_answer": answer} + (run / "trajectory.json").write_text(json.dumps(trajectory), encoding="utf-8") + result = subprocess.run([sys.executable, str(VERIFY_DIR / f"verify_{task}.py"), "--run_dir", str(run), "--initial_db", str(initial), "--after_db", str(after), "--no_llm", "true"], capture_output=True, text=True, timeout=30, check=False) + try: verdict = json.loads(result.stdout) + except json.JSONDecodeError as error: self.fail(f"task {task} invalid output: {result.stdout!r} {result.stderr!r}: {error}") + return result.returncode, verdict + + @staticmethod + def mutate_cart(connection: sqlite3.Connection) -> None: + uid = connection.execute("SELECT id FROM users WHERE email='carol.d@test.com'").fetchone()[0] + pid = connection.execute("SELECT id FROM products WHERE sku='TGT91151386'").fetchone()[0] + connection.execute("INSERT INTO cart_items(id,user_id,product_id,quantity,fulfillment_method,created_at) VALUES(?,?,?,?,?,?)", (next_id(connection, "cart_items"), uid, pid, 1, "delivery", "2026-04-01 10:00:00")) + + @staticmethod + def _insert_order(connection: sqlite3.Connection, *, email: str, sku: str, quantity: int, fulfillment: str, street: str = "", city: str = "", state: str = "", zip_code: str = "", store_slug: str | None = None, slot_label: str = "") -> tuple[str, int, float]: + uid = connection.execute("SELECT id FROM users WHERE email=?", (email,)).fetchone()[0] + product_id, price = connection.execute("SELECT id,price FROM products WHERE sku=?", (sku,)).fetchone() + order_id = next_id(connection, "orders"); number = f"TGT-TEST-{order_id}"; subtotal = round(price * quantity, 2); tax = round(subtotal * 0.086, 2); total = round(subtotal + tax, 2) + store_id = connection.execute("SELECT id FROM stores WHERE slug=?", (store_slug,)).fetchone()[0] if store_slug else None + delivery_id = connection.execute("SELECT id FROM delivery_options ORDER BY id LIMIT 1").fetchone()[0] if fulfillment == "delivery" else None + connection.execute("INSERT INTO orders(id,user_id,order_number,email,status,subtotal,tax,total,fulfillment_method,store_id,delivery_option_id,shipping_name,shipping_street,shipping_city,shipping_state,shipping_zip,payment_brand,payment_last4,confirmation_note,pickup_slot_label,placed_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (order_id, uid, number, email, "Processing" if fulfillment == "pickup" else "Preparing shipment", subtotal, tax, total, fulfillment, store_id, delivery_id, "Bob Chen", street, city, state, zip_code, "Demo Visa", "1111", "Demo", slot_label, "2026-04-01 10:00:00")) + connection.execute("INSERT INTO order_items(id,order_id,product_id,item_name,quantity,unit_price,protection_plan_name) VALUES(?,?,?,?,?,?,?)", (next_id(connection, "order_items"), order_id, product_id, connection.execute("SELECT name FROM products WHERE id=?", (product_id,)).fetchone()[0], quantity, price, "")) + connection.execute("INSERT INTO payment_mocks(id,order_id,amount,card_label,auth_status,approval_code,created_at) VALUES(?,?,?,?,?,?,?)", (next_id(connection, "payment_mocks"), order_id, total, "Demo Visa", "Approved", "TEST", "2026-04-01 10:00:00")) + connection.execute("UPDATE reward_accounts SET points_balance=points_balance+? WHERE user_id=?", (int(subtotal), uid)) + connection.execute("INSERT INTO reward_activities(id,user_id,points_delta,title,note,created_at) VALUES(?,?,?,?,?,?)", (next_id(connection, "reward_activities"), uid, int(subtotal), f"Points from order {number}", "Demo", "2026-04-01 10:00:00")) + return number, order_id, subtotal + + @classmethod + def mutate_delivery_order(cls, connection: sqlite3.Connection) -> None: + cls._insert_order(connection, email="bob.c@test.com", sku=SKUS[11], quantity=1, fulfillment="delivery", street="123 Main St", city="Denver", state="CO", zip_code="80202") + + @classmethod + def mutate_pickup_order(cls, connection: sqlite3.Connection) -> None: + number, _, _ = cls._insert_order(connection, email="bob.c@test.com", sku=SKUS[17], quantity=2, fulfillment="pickup", store_slug="denver-stapleton", slot_label="Tomorrow 9:00 AM - 11:00 AM") + product_id = connection.execute("SELECT id FROM products WHERE sku=?", (SKUS[17],)).fetchone()[0] + store_id = connection.execute("SELECT id FROM stores WHERE slug='denver-stapleton'").fetchone()[0] + connection.execute("UPDATE store_inventory SET quantity=quantity-2 WHERE product_id=? AND store_id=?", (product_id, store_id)) + connection.execute("UPDATE pickup_slots SET available_capacity=available_capacity-1 WHERE store_id=? AND day_label='Tomorrow' AND time_window='9:00 AM - 11:00 AM'", (store_id,)) + + @staticmethod + def mutate_wishlist_add(connection: sqlite3.Connection) -> None: + uid = connection.execute("SELECT id FROM users WHERE email='alice.j@test.com'").fetchone()[0]; pid = connection.execute("SELECT id FROM products WHERE sku=?", (SKUS[12],)).fetchone()[0] + connection.execute("INSERT INTO wishlist_items(id,user_id,product_id,created_at) VALUES(?,?,?,?)", (next_id(connection, "wishlist_items"), uid, pid, "2026-04-01 10:00:00")) + + @staticmethod + def mutate_wishlist_remove(connection: sqlite3.Connection) -> None: + connection.execute("DELETE FROM wishlist_items WHERE user_id=(SELECT id FROM users WHERE email='alice.j@test.com') AND product_id=(SELECT id FROM products WHERE sku='TGT12954143')") + + @staticmethod + def mutate_ticket(connection: sqlite3.Connection) -> None: + uid = connection.execute("SELECT id FROM users WHERE email='bob.c@test.com'").fetchone()[0] + connection.execute("INSERT INTO support_tickets(id,user_id,subject,status,channel,summary,created_at) VALUES(?,?,?,?,?,?,?)", (next_id(connection, "support_tickets"), uid, "Order arrived damaged", "Open", "Email", "The package arrived with visible damage.", "2026-04-01 10:00:00")) + + @staticmethod + def mutate_review(connection: sqlite3.Connection) -> None: + pid, rating, count = connection.execute("SELECT id,rating,review_count FROM products WHERE sku=?", (SKUS[19],)).fetchone() + connection.execute("INSERT INTO reviews(id,product_id,author_name,title,body,rating,verified,created_at) VALUES(?,?,?,?,?,?,?,?)", (next_id(connection, "reviews"), pid, "Carol Diaz", "Great sound for the size", "Clear sound and useful size for a small room.", 4, 0, "2026-04-01 10:00:00")) + connection.execute("UPDATE products SET rating=?,review_count=? WHERE id=?", (round((rating * count + 4) / (count + 1), 1), count + 1, pid)) + + def positive_case(self, task: int): + product = lambda number: f"/product/{SKUS[number]}" + red = "/search?q=Red+Baron" + cases = { + 0: ([navigate("/support")] + transition("/support", "/support/returns-and-exchanges"), "Opened beauty items: 60 days. Target owned brands: one year.", None), + 1: ([navigate("/search?q=yoga+mat")] + transition("/search?q=yoga+mat", product(1)), "Nitrile Butadiene Rubber (NBR), 71 inches long.", None), + 2: ([navigate("/category/grocery")] + transition("/category/grocery", product(2)), "Sodium is 610 mg per serving.", None), + 3: ([navigate(red)] + transition(red, "/product/TGT13376389") + [navigate(red)] + transition(red, "/product/TGT13334000"), "Four Cheese has less sodium: 710 mg versus Pepperoni at 790 mg.", None), + 4: ([navigate("/search?q=Mr+Coffee")] + transition("/search?q=Mr+Coffee", product(4)), "74% would recommend it; Easy to Clean scored highest.", None), + 5: ([navigate("/category/electronics?brand=sony")] + transition("/category/electronics?brand=sony", product(5)), "The longer plan covers accidental handling and costs $22.95 more ($59.67 versus $36.72).", None), + 6: ([navigate("/category/pets?brand=boots-barkley&availability=pickup&deals=1&sort=price-asc")], "Cuddler Dog Bed - Blue - Boots & Barkley, $24.99.", None), + 7: ([navigate(red)] + transition(red, "/product/TGT13333997") + [click("/product/TGT13333997", "/product/TGT13333997"), navigate("/product/TGT13333997"), navigate(red)] + transition(red, "/product/TGT31168522") + [click("/product/TGT31168522", "/product/TGT31168522"), navigate("/product/TGT31168522"), navigate("/compare")], "Supreme has less sodium: 650 mg versus 810 mg, a 160 mg difference.", None), + 8: (login_steps("david.k@test.com") + transition("/account", "/account/orders"), "The Processing order is TGT-240013, total $32.61.", None), + 9: (login_steps("david.k@test.com") + transition("/account", "/account/rewards"), "The points balance is 2,185 points.", None), + 10: (login_steps("carol.d@test.com") + [navigate("/search?q=yoga+mat")] + transition("/search?q=yoga+mat", product(10)) + [click(product(10), product(10)), navigate(product(10))] + transition(product(10), "/cart"), "The cart subtotal is $408.96.", self.mutate_cart), + 11: (login_steps("bob.c@test.com") + [navigate("/search?q=Tide+Ultra+Oxi")] + transition("/search?q=Tide+Ultra+Oxi", product(11)) + [click(product(11), "/cart"), navigate("/cart"), navigate("/checkout/shipping"), enter("/checkout/shipping", "123 Main St"), enter("/checkout/shipping", "Denver"), enter("/checkout/shipping", "CO"), enter("/checkout/shipping", "80202"), click("/checkout/shipping", "/checkout/payment"), navigate("/checkout/payment"), click("/checkout/payment", "/checkout/review"), navigate("/checkout/review"), click("/checkout/review", "/checkout/confirmation"), navigate("/checkout/confirmation")], "Order TGT-TEST-17 was confirmed.", self.mutate_delivery_order), + 12: (login_steps("alice.j@test.com") + [navigate("/search?q=Colgate+Total+Whitening")] + transition("/search?q=Colgate+Total+Whitening", product(12)) + [click(product(12), product(12)), navigate(product(12)), navigate("/account/wishlist")], "The bottom item is Organic Mini Sandwich Cheddar Cheese Crackers.", self.mutate_wishlist_add), + 13: ([navigate("/stores")] + transition("/stores", "/stores/denver-stapleton"), "7400 E 29th Ave; Drive Up.", None), + 14: (login_steps("alice.j@test.com") + transition("/account", "/account/wishlist") + [click("/account/wishlist", "/account/wishlist"), navigate("/account/wishlist")], "Katie's Burrata Margherita; Cinnamon Toast Crunch; Organic Mini Sandwich Cheddar Cheese Crackers.", self.mutate_wishlist_remove), + 15: ([navigate(red)] + sum((transition(red, f"/product/{sku}") + [navigate(red)] for sku in ("TGT13333997", "TGT13334000", "TGT31168521", "TGT13376389", "TGT31168522")), []), "Red Baron Supreme Classic Crust has the lowest sodium at 650 mg per serving.", None), + 16: ([navigate("/search?q=Ninja+DualBrew")] + transition("/search?q=Ninja+DualBrew", "/product/TGT94682442") + [navigate("/search?q=Cuisinart+14+Cup")] + transition("/search?q=Cuisinart+14+Cup", "/product/TGT94139349"), "Ninja is higher at 66%; Cuisinart is 59%.", None), + 17: (login_steps("bob.c@test.com") + [navigate("/search?q=Colgate+Optic+White")] + transition("/search?q=Colgate+Optic+White", product(17)) + [enter(product(17), "2", "select"), click(product(17), "/cart"), navigate("/cart"), navigate("/checkout/pickup"), enter("/checkout/pickup", "Tomorrow 9:00 AM - 11:00 AM", "select"), click("/checkout/pickup", "/checkout/payment"), navigate("/checkout/payment"), click("/checkout/payment", "/checkout/review"), navigate("/checkout/review"), click("/checkout/review", "/checkout/confirmation"), navigate("/checkout/confirmation")], "Order TGT-TEST-17 was confirmed.", self.mutate_pickup_order), + 18: (login_steps("bob.c@test.com") + [navigate("/support")] + transition("/support", "/support/contact") + [enter("/support/contact", "Order arrived damaged"), enter("/support/contact", "The box arrived crushed and the product was damaged."), enter("/support/contact", "Email", "select"), click("/support/contact", "/account/support"), navigate("/account/support")], "The request status is Open.", self.mutate_ticket), + 19: (login_steps("carol.d@test.com") + [navigate("/search?q=Beats+Pill")] + transition("/search?q=Beats+Pill", product(19)) + [enter(product(19), "4", "select"), enter(product(19), "Great sound for the size"), enter(product(19), "Clear sound and useful size for a small room."), click(product(19), product(19)), navigate(product(19))], "The review 'Great sound for the size' appears on the product page.", self.mutate_review), + } + return cases[task] + + def test_all_positive_cases(self) -> None: + for task in range(20): + with self.subTest(task=task): + steps, answer, mutate = self.positive_case(task); code, verdict = self.run_verifier(task, steps, answer, mutate) + self.assertEqual(0, code, verdict); self.assertTrue(verdict["pass"], verdict) + + def test_wrong_task_id_fails_all(self) -> None: + for task in range(20): + with self.subTest(task=task): + steps, answer, mutate = self.positive_case(task); code, verdict = self.run_verifier(task, steps, answer, mutate, "Target--999") + self.assertNotEqual(0, code); self.assertEqual("task_id_matches", verdict["reason"]) + + def test_answer_only_fails_all(self) -> None: + for task in range(20): + with self.subTest(task=task): + _, answer, mutate = self.positive_case(task); code, verdict = self.run_verifier(task, [], answer, mutate) + self.assertNotEqual(0, code); self.assertFalse(verdict["pass"]) + + def test_external_origin_and_url_only_fail(self) -> None: + steps = [{"url": "https://attacker.invalid/?next=/product/TGT91151386", "action": "navigate", "params": {}}] + code, verdict = self.run_verifier(1, steps, "NBR, 71 inches") + self.assertNotEqual(0, code); self.assertFalse(verdict["pass"]) + + def test_negated_answers_fail(self) -> None: + cases = { + 2: "It is not 610 mg sodium; it is 450 mg.", + 4: "It is not 74%, and Easy to Clean did not score highest.", + 5: "The 3-year plan does not cover accidental handling; the gap is $22.95.", + 10: "The subtotal is not $408.96; it is $1.00.", + 15: "Supreme is not the answer, though its label says 650 mg.", + } + for task, answer in cases.items(): + with self.subTest(task=task): + steps, _, mutate = self.positive_case(task); code, verdict = self.run_verifier(task, steps, answer, mutate) + self.assertNotEqual(0, code); self.assertFalse(verdict["pass"]) + + def test_state_tasks_reject_unrelated_changes(self) -> None: + for task in (10, 11, 12, 14, 17, 18, 19): + with self.subTest(task=task): + steps, answer, base_mutate = self.positive_case(task) + def mutate(connection, base_mutate=base_mutate): + base_mutate(connection); connection.execute("UPDATE users SET city='Unrelated mutation' WHERE email='david.k@test.com'") + code, verdict = self.run_verifier(task, steps, answer, mutate) + self.assertNotEqual(0, code); self.assertFalse(verdict["pass"]) + + def test_natural_equivalents_pass(self) -> None: + steps, _, _ = self.positive_case(5); code, verdict = self.run_verifier(5, steps, "The longer plan includes accidental handling; prices are $59.67 and $36.72.") + self.assertEqual(0, code, verdict) + steps, _, _ = self.positive_case(13); code, verdict = self.run_verifier(13, steps, "The address is 7400 E 29th Ave and it offers Order Pickup.") + self.assertEqual(0, code, verdict) + + def test_checkout_rejects_wrong_address_and_wrong_store(self) -> None: + steps, answer, _ = self.positive_case(11) + def wrong_address(connection): self._insert_order(connection, email="bob.c@test.com", sku=SKUS[11], quantity=1, fulfillment="delivery", street="999 Wrong Rd", city="Boston", state="MA", zip_code="02108") + code, verdict = self.run_verifier(11, steps, answer, wrong_address); self.assertNotEqual(0, code); self.assertFalse(verdict["pass"]) + steps, answer, _ = self.positive_case(17) + def wrong_store(connection): self._insert_order(connection, email="bob.c@test.com", sku=SKUS[17], quantity=2, fulfillment="pickup", store_slug="austin-domain", slot_label="Tomorrow 9:00 AM - 11:00 AM") + code, verdict = self.run_verifier(17, steps, answer, wrong_store); self.assertNotEqual(0, code); self.assertFalse(verdict["pass"]) + + +if __name__ == "__main__": unittest.main() diff --git a/sites/target/verify/verify_0.py b/sites/target/verify/verify_0.py new file mode 100644 index 00000000..43b251f6 --- /dev/null +++ b/sites/target/verify/verify_0.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +from verify_lib import ( + Judge, check_common, clicked_transition, database_unchanged, final_answer, + number_bound_to, parse_args, resolve_db, visited_in_order, + load_run, contains_any, +) + +TASK_ID = "Target--0" +ARTICLE = "/support/returns-and-exchanges" + + +def main() -> None: + args = parse_args(); trajectory = load_run(args.run_dir); answer = final_answer(trajectory); judge = Judge(TASK_ID) + check_common(judge, trajectory, TASK_ID) + judge.check("ordered_help_article_navigation", visited_in_order(trajectory, [("/support", {}), (ARTICLE, {})]), "Support precedes article") + judge.check("clicked_returns_article", clicked_transition(trajectory, "/support", ARTICLE), "article opened by click") + beauty = number_bound_to(answer, 60, ("opened beauty", "beauty")) and contains_any(answer, ("day", "days")) + owned = contains_any(answer, ("one year", "1 year", "12 months")) or (number_bound_to(answer, 365, ("target owned", "owned brands")) and contains_any(answer, ("day", "days"))) + judge.check("answer_opened_beauty_window", beauty, repr(answer)) + judge.check("answer_owned_brand_window", owned, repr(answer)) + initial = resolve_db(args.initial_db, args.container, "instance_seed"); after = resolve_db(args.after_db, args.container, "instance") + judge.check("read_only_database_unchanged", database_unchanged(initial, after), "complete database comparison") + judge.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..32951b7a --- /dev/null +++ b/sites/target/verify/verify_1.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +from verify_lib import ( + Judge, check_common, clicked_transition, contains_any, database_unchanged, + final_answer, has_number, load_run, parse_args, resolve_db, + visited_in_order, +) + +TASK_ID = "Target--1" +SKU = "TGT91151386" +PRODUCT_PATH = f"/product/{SKU}" + + +def main() -> None: + args = parse_args(); trajectory = load_run(args.run_dir); answer = final_answer(trajectory); judge = Judge(TASK_ID) + check_common(judge, trajectory, TASK_ID) + judge.check("ordered_search_to_product", visited_in_order(trajectory, [("/search", {"q": "yoga mat"}), (PRODUCT_PATH, {})]), "search precedes product") + judge.check("clicked_product_result", clicked_transition(trajectory, "/search", PRODUCT_PATH), "product opened from results") + judge.check("answer_material", contains_any(answer, ("nitrile butadiene rubber", "nbr")), repr(answer)) + judge.check("answer_length", has_number(answer, 71) and contains_any(answer, ("inch", "inches", '"')), repr(answer)) + initial = resolve_db(args.initial_db, args.container, "instance_seed"); after = resolve_db(args.after_db, args.container, "instance") + judge.check("read_only_database_unchanged", database_unchanged(initial, after), "complete database comparison") + judge.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..bff3fcae --- /dev/null +++ b/sites/target/verify/verify_10.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +from verify_lib import ( + Judge, cart_snapshot, changed_tables, check_common, clicked_transition, + final_answer, has_money, load_run, login_submitted_as, parse_args, + resolve_db, row_dicts, submitted_from_path, visited_in_order, +) + +TASK_ID = "Target--10" +EMAIL = "carol.d@test.com" +SKU = "TGT91151386" +PRODUCT_PATH = f"/product/{SKU}" + + +def cart_subtotal(path: str, email: str) -> float: + rows = row_dicts(path, "SELECT c.quantity,p.price,COALESCE(pp.price,0) AS plan_price FROM cart_items c JOIN users u ON u.id=c.user_id JOIN products p ON p.id=c.product_id LEFT JOIN protection_plans pp ON pp.id=c.protection_plan_id WHERE lower(u.email)=lower(?)", (email,)) + return round(sum((row["price"] + row["plan_price"]) * row["quantity"] for row in rows), 2) + + +def main() -> None: + args = parse_args(); trajectory = load_run(args.run_dir); answer = final_answer(trajectory); judge = Judge(TASK_ID) + check_common(judge, trajectory, TASK_ID) + judge.check("login_as_carol", login_submitted_as(trajectory, EMAIL), EMAIL) + judge.check("ordered_product_add_cart", visited_in_order(trajectory, [("/login", {}), (PRODUCT_PATH, {}), ("/cart", {})]), "login, product, cart") + judge.check("product_opened_from_search", clicked_transition(trajectory, "/search", PRODUCT_PATH), "product clicked from search") + judge.check("add_form_submitted", submitted_from_path(trajectory, PRODUCT_PATH), "product form submitted") + initial = resolve_db(args.initial_db, args.container, "instance_seed"); after = resolve_db(args.after_db, args.container, "instance") + readable = bool(initial and after); judge.check("databases_readable", readable, f"initial={initial} after={after}") + if readable: + before = cart_snapshot(initial, EMAIL); now = cart_snapshot(after, EMAIL) + before_by_sku = {row["sku"]: row for row in before}; now_by_sku = {row["sku"]: row for row in now} + judge.check("target_absent_initially", SKU not in before_by_sku, repr(before)) + judge.check("target_added_once", SKU in now_by_sku and now_by_sku[SKU]["quantity"] == 1, repr(now)) + unchanged_existing = all(sku in now_by_sku and {k: v for k, v in row.items() if k != "id"} == {k: v for k, v in now_by_sku[sku].items() if k != "id"} for sku, row in before_by_sku.items()) + judge.check("existing_cart_rows_unchanged", unchanged_existing and set(now_by_sku) == set(before_by_sku) | {SKU}, repr(now)) + judge.check("only_cart_table_changed", changed_tables(initial, after) == {"cart_items"}, repr(changed_tables(initial, after))) + subtotal = cart_subtotal(after, EMAIL) + judge.check("answer_subtotal", has_money(answer, subtotal), f"expected={subtotal:.2f} answer={answer!r}") + judge.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..4efacae1 --- /dev/null +++ b/sites/target/verify/verify_11.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +from verify_lib import ( + Judge, cart_snapshot, changed_tables, check_common, clicked_transition, + contains_all, entered_text, final_answer, load_run, login_submitted_as, + order_items, orders_snapshot, parse_args, resolve_db, row_dicts, + submitted_from_path, table_snapshot, visited_in_order, +) + +TASK_ID = "Target--11" +EMAIL = "bob.c@test.com" +SKU = "TGT94640332" +PRODUCT_PATH = f"/product/{SKU}" + + +def main() -> None: + args = parse_args(); trajectory = load_run(args.run_dir); answer = final_answer(trajectory); judge = Judge(TASK_ID) + check_common(judge, trajectory, TASK_ID) + judge.check("login_as_bob", login_submitted_as(trajectory, EMAIL), EMAIL) + checkpoints = [("/login", {}), ("/search", {"q": "Tide Ultra Oxi"}), (PRODUCT_PATH, {}), ("/cart", {}), ("/checkout/shipping", {}), ("/checkout/payment", {}), ("/checkout/review", {}), ("/checkout/confirmation", {})] + judge.check("ordered_delivery_checkout", visited_in_order(trajectory, checkpoints), repr(checkpoints)) + judge.check("clicked_tide_result", clicked_transition(trajectory, "/search", PRODUCT_PATH), "product clicked from search") + judge.check("checkout_forms_submitted", all(submitted_from_path(trajectory, path) for path in (PRODUCT_PATH, "/checkout/shipping", "/checkout/payment", "/checkout/review")), "add, shipping, payment, place order") + address_inputs = all(entered_text(trajectory, value, "/checkout/shipping") for value in ("123 Main St", "Denver", "CO", "80202")) + judge.check("requested_address_entered", address_inputs, "exact address fields") + initial = resolve_db(args.initial_db, args.container, "instance_seed"); after = resolve_db(args.after_db, args.container, "instance") + readable = bool(initial and after); judge.check("databases_readable", readable, f"initial={initial} after={after}") + if readable: + before_orders = orders_snapshot(initial, EMAIL); after_orders = orders_snapshot(after, EMAIL) + before_ids = {row["id"] for row in before_orders}; created = [row for row in after_orders if row["id"] not in before_ids] + judge.check("bob_cart_clean_before_and_after", cart_snapshot(initial, EMAIL) == [] and cart_snapshot(after, EMAIL) == [], f"before={cart_snapshot(initial, EMAIL)} after={cart_snapshot(after, EMAIL)}") + judge.check("exactly_one_new_order", len(created) == 1 and len(after_orders) == len(before_orders) + 1 and all(row in after_orders for row in before_orders), repr(created)) + if len(created) == 1: + order = created[0]; number = order["order_number"] + exact_order = order["fulfillment_method"] == "delivery" and order["shipping_street"] == "123 Main St" and order["shipping_city"] == "Denver" and order["shipping_state"] == "CO" and order["shipping_zip"] == "80202" + judge.check("delivery_address_exact", exact_order, repr(order)) + items = order_items(after, number) + judge.check("only_target_item_ordered", len(items) == 1 and items[0]["sku"] == SKU and items[0]["quantity"] == 1, repr(items)) + payments = row_dicts(after, "SELECT * FROM payment_mocks WHERE order_id=?", (order["id"],)) + judge.check("one_payment_for_order", len(payments) == 1 and abs(payments[0]["amount"] - order["total"]) < 0.005, repr(payments)) + judge.check("answer_real_order_number", contains_all(answer, (number,)), repr(answer)) + before_reward = row_dicts(initial, "SELECT ra.* FROM reward_accounts ra JOIN users u ON u.id=ra.user_id WHERE lower(u.email)=lower(?)", (EMAIL,))[0] + after_reward = row_dicts(after, "SELECT ra.* FROM reward_accounts ra JOIN users u ON u.id=ra.user_id WHERE lower(u.email)=lower(?)", (EMAIL,))[0] + judge.check("reward_points_exact", after_reward["points_balance"] == before_reward["points_balance"] + int(order["subtotal"]), f"before={before_reward} after={after_reward}") + before_activities = table_snapshot(initial, "reward_activities"); after_activities = table_snapshot(after, "reward_activities") + new_activities = [row for row in after_activities if row not in before_activities] + judge.check("one_reward_activity", len(new_activities) == 1 and new_activities[0][2] == int(order["subtotal"]) and all(row in after_activities for row in before_activities), repr(new_activities)) + for table in ("order_items", "payment_mocks"): + before_rows = table_snapshot(initial, table); after_rows = table_snapshot(after, table) + judge.check(f"existing_{table}_intact", all(row in after_rows for row in before_rows), f"before={len(before_rows)} after={len(after_rows)}") + expected_tables = {"orders", "order_items", "payment_mocks", "reward_accounts", "reward_activities"} + judge.check("only_expected_tables_changed", changed_tables(initial, after) == expected_tables, repr(changed_tables(initial, after))) + judge.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..cfb6a594 --- /dev/null +++ b/sites/target/verify/verify_12.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +from verify_lib import ( + Judge, changed_tables, check_common, clicked_transition, contains_all, + final_answer, load_run, login_submitted_as, parse_args, resolve_db, + submitted_from_path, visited_in_order, wishlist_snapshot, +) + +TASK_ID = "Target--12" +EMAIL = "alice.j@test.com" +SKU = "TGT1012287965" +PRODUCT_PATH = f"/product/{SKU}" +BOTTOM_TOKENS = ("Organic Mini Sandwich", "Cheddar Cheese Crackers") + + +def main() -> None: + args = parse_args(); trajectory = load_run(args.run_dir); answer = final_answer(trajectory); judge = Judge(TASK_ID) + check_common(judge, trajectory, TASK_ID) + judge.check("login_as_alice", login_submitted_as(trajectory, EMAIL), EMAIL) + judge.check("ordered_save_and_wishlist", visited_in_order(trajectory, [("/login", {}), (PRODUCT_PATH, {}), ("/account/wishlist", {})]), "login, product, wishlist") + judge.check("product_opened_from_search", clicked_transition(trajectory, "/search", PRODUCT_PATH), "product clicked from search") + judge.check("wishlist_form_submitted", submitted_from_path(trajectory, PRODUCT_PATH), "detail-page form submitted") + initial = resolve_db(args.initial_db, args.container, "instance_seed"); after = resolve_db(args.after_db, args.container, "instance") + readable = bool(initial and after); judge.check("databases_readable", readable, f"initial={initial} after={after}") + if readable: + before = wishlist_snapshot(initial, EMAIL); now = wishlist_snapshot(after, EMAIL) + before_by_id = {row["id"]: row for row in before}; added = [row for row in now if row["id"] not in before_by_id] + judge.check("exactly_target_added", len(added) == 1 and added[0]["sku"] == SKU and len(now) == len(before) + 1, repr(added)) + judge.check("existing_wishlist_intact", all(row in now for row in before), repr(now)) + judge.check("only_wishlist_changed", changed_tables(initial, after) == {"wishlist_items"}, repr(changed_tables(initial, after))) + judge.check("answer_bottom_item", contains_all(answer, BOTTOM_TOKENS), repr(answer)) + judge.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..5b60e121 --- /dev/null +++ b/sites/target/verify/verify_13.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +from verify_lib import ( + Judge, check_common, clicked_transition, contains_all, contains_any, + database_unchanged, final_answer, load_run, parse_args, resolve_db, + visited_in_order, +) + +TASK_ID = "Target--13" +STORE_PATH = "/stores/denver-stapleton" + + +def main() -> None: + args = parse_args(); trajectory = load_run(args.run_dir); answer = final_answer(trajectory); judge = Judge(TASK_ID) + check_common(judge, trajectory, TASK_ID) + judge.check("ordered_store_navigation", visited_in_order(trajectory, [("/stores", {}), (STORE_PATH, {})]), "store list before detail") + judge.check("clicked_denver_store", clicked_transition(trajectory, "/stores", STORE_PATH), "Denver opened from list") + judge.check("answer_street_address", contains_all(answer, ("7400", "E 29th Ave")), repr(answer)) + judge.check("answer_pickup_service", contains_any(answer, ("Order Pickup", "Drive Up")), repr(answer)) + initial = resolve_db(args.initial_db, args.container, "instance_seed"); after = resolve_db(args.after_db, args.container, "instance") + judge.check("read_only_database_unchanged", database_unchanged(initial, after), "complete database comparison") + judge.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..d84fc996 --- /dev/null +++ b/sites/target/verify/verify_14.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +from verify_lib import ( + Judge, changed_tables, check_common, clicked_transition, contains_all, + final_answer, load_run, login_submitted_as, parse_args, resolve_db, + submitted_from_path, visited_in_order, wishlist_snapshot, +) + +TASK_ID = "Target--14" +EMAIL = "alice.j@test.com" +REMOVED_SKU = "TGT12954143" +REMAINING_TOKEN_GROUPS = ( + ("Katie's Burrata Margherita",), + ("Cinnamon Toast Crunch",), + ("Organic Mini Sandwich", "Cheddar Cheese Crackers"), +) + + +def main() -> None: + args = parse_args(); trajectory = load_run(args.run_dir); answer = final_answer(trajectory); judge = Judge(TASK_ID) + check_common(judge, trajectory, TASK_ID) + judge.check("login_as_alice", login_submitted_as(trajectory, EMAIL), EMAIL) + judge.check("ordered_login_to_wishlist", visited_in_order(trajectory, [("/login", {}), ("/account", {}), ("/account/wishlist", {})]), "login, account, wishlist") + judge.check("clicked_wishlist", clicked_transition(trajectory, "/account", "/account/wishlist"), "wishlist opened from account") + judge.check("removal_form_submitted", submitted_from_path(trajectory, "/account/wishlist", "/account/wishlist"), "wishlist removal submitted") + initial = resolve_db(args.initial_db, args.container, "instance_seed"); after = resolve_db(args.after_db, args.container, "instance") + readable = bool(initial and after); judge.check("databases_readable", readable, f"initial={initial} after={after}") + if readable: + before = wishlist_snapshot(initial, EMAIL); now = wishlist_snapshot(after, EMAIL) + removed = [row for row in before if row not in now] + judge.check("only_named_item_removed", len(removed) == 1 and removed[0]["sku"] == REMOVED_SKU and len(now) == len(before) - 1, repr(removed)) + judge.check("remaining_rows_exact", all(row in before for row in now), repr(now)) + judge.check("only_wishlist_changed", changed_tables(initial, after) == {"wishlist_items"}, repr(changed_tables(initial, after))) + judge.check("answer_names_all_remaining", all(contains_all(answer, group) for group in REMAINING_TOKEN_GROUPS), repr(answer)) + judge.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..c86b553c --- /dev/null +++ b/sites/target/verify/verify_15.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +from verify_lib import ( + Judge, affirmative_contains, check_common, clicked_transition, contains_any, database_unchanged, + final_answer, load_run, number_bound_to, parse_args, resolve_db, + visited_in_order, +) + +TASK_ID = "Target--15" +REQUIRED_SKUS = ( + "TGT13333997", + "TGT13334000", + "TGT31168521", + "TGT13376389", + "TGT31168522", +) +SUPREME_PATH = "/product/TGT13333997" + + +def main() -> None: + args = parse_args(); trajectory = load_run(args.run_dir); answer = final_answer(trajectory); judge = Judge(TASK_ID) + check_common(judge, trajectory, TASK_ID) + search_seen = visited_in_order(trajectory, [("/search", {"q": "Red Baron"}), (SUPREME_PATH, {})]) + judge.check("red_baron_search", search_seen, "broad search precedes products") + clicked = [sku for sku in REQUIRED_SKUS if clicked_transition(trajectory, "/search", f"/product/{sku}")] + judge.check("all_five_products_opened", len(clicked) == len(REQUIRED_SKUS), repr(clicked)) + judge.check("answer_supreme_650", number_bound_to(answer, 650, ("supreme", "supreme classic crust")), repr(answer)) + judge.check("supreme_identified_lowest", affirmative_contains(answer, "supreme"), repr(answer)) + judge.check("answer_uses_sodium_unit", contains_any(answer, ("mg", "milligram", "milligrams")), repr(answer)) + initial = resolve_db(args.initial_db, args.container, "instance_seed"); after = resolve_db(args.after_db, args.container, "instance") + judge.check("read_only_database_unchanged", database_unchanged(initial, after), "complete database comparison") + judge.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..84af277f --- /dev/null +++ b/sites/target/verify/verify_16.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +from verify_lib import ( + Judge, check_common, claims_relation, clicked_transition, contains_any, database_unchanged, + final_answer, load_run, number_bound_to, parse_args, resolve_db, + visited_in_order, +) + +TASK_ID = "Target--16" +NINJA = "TGT94682442" +CUISINART = "TGT94139349" +NINJA_PATH = f"/product/{NINJA}" +CUISINART_PATH = f"/product/{CUISINART}" + + +def main() -> None: + args = parse_args(); trajectory = load_run(args.run_dir); answer = final_answer(trajectory); judge = Judge(TASK_ID) + check_common(judge, trajectory, TASK_ID) + ninja_flow = visited_in_order(trajectory, [("/search", {}), (NINJA_PATH, {})]) and clicked_transition(trajectory, "/search", NINJA_PATH) + cuisinart_flow = visited_in_order(trajectory, [("/search", {}), (CUISINART_PATH, {})]) and clicked_transition(trajectory, "/search", CUISINART_PATH) + judge.check("opened_both_from_search", ninja_flow and cuisinart_flow, f"ninja={ninja_flow} cuisinart={cuisinart_flow}") + judge.check("ninja_percent_bound", number_bound_to(answer, 66, ("ninja", "dualbrew")), repr(answer)) + judge.check("cuisinart_percent_bound", number_bound_to(answer, 59, ("cuisinart",)), repr(answer)) + judge.check("ninja_identified_higher", claims_relation(answer, ("ninja", "dualbrew"), ("cuisinart",), ("higher", "more", "greater")), repr(answer)) + judge.check("answer_uses_percentage_unit", contains_any(answer, ("%", "percent", "percentage")), repr(answer)) + initial = resolve_db(args.initial_db, args.container, "instance_seed"); after = resolve_db(args.after_db, args.container, "instance") + judge.check("read_only_database_unchanged", database_unchanged(initial, after), "complete database comparison") + judge.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..aeb6adaa --- /dev/null +++ b/sites/target/verify/verify_17.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +from verify_lib import ( + Judge, cart_snapshot, changed_tables, check_common, clicked_transition, + contains_all, entered_text, final_answer, load_run, login_submitted_as, + order_items, orders_snapshot, parse_args, resolve_db, row_dicts, + submitted_from_path, table_snapshot, visited_in_order, +) + +TASK_ID = "Target--17" +EMAIL = "bob.c@test.com" +SKU = "TGT85566854" +PRODUCT_PATH = f"/product/{SKU}" +STORE_SLUG = "denver-stapleton" +SLOT_LABEL = "Tomorrow 9:00 AM - 11:00 AM" + + +def changed_rows(before: list[tuple], after: list[tuple]) -> tuple[list[tuple], list[tuple]]: + return ([row for row in before if row not in after], [row for row in after if row not in before]) + + +def main() -> None: + args = parse_args(); trajectory = load_run(args.run_dir); answer = final_answer(trajectory); judge = Judge(TASK_ID) + check_common(judge, trajectory, TASK_ID) + judge.check("login_as_bob", login_submitted_as(trajectory, EMAIL), EMAIL) + checkpoints = [("/login", {}), (PRODUCT_PATH, {}), ("/cart", {}), ("/checkout/pickup", {}), ("/checkout/payment", {}), ("/checkout/review", {}), ("/checkout/confirmation", {})] + judge.check("ordered_pickup_checkout", visited_in_order(trajectory, checkpoints), repr(checkpoints)) + judge.check("product_opened_from_search", clicked_transition(trajectory, "/search", PRODUCT_PATH), "product clicked from results") + judge.check("checkout_forms_submitted", all(submitted_from_path(trajectory, path) for path in (PRODUCT_PATH, "/checkout/pickup", "/checkout/payment", "/checkout/review")), "add, pickup, payment, place order") + judge.check("requested_quantity_and_pickup_values_entered", entered_text(trajectory, "2", PRODUCT_PATH) and entered_text(trajectory, SLOT_LABEL, "/checkout/pickup"), SLOT_LABEL) + initial = resolve_db(args.initial_db, args.container, "instance_seed"); after = resolve_db(args.after_db, args.container, "instance") + readable = bool(initial and after); judge.check("databases_readable", readable, f"initial={initial} after={after}") + if readable: + before_orders = orders_snapshot(initial, EMAIL); after_orders = orders_snapshot(after, EMAIL); before_ids = {row["id"] for row in before_orders}; created = [row for row in after_orders if row["id"] not in before_ids] + judge.check("bob_cart_clean_before_and_after", cart_snapshot(initial, EMAIL) == [] and cart_snapshot(after, EMAIL) == [], f"before={cart_snapshot(initial, EMAIL)} after={cart_snapshot(after, EMAIL)}") + judge.check("exactly_one_new_order", len(created) == 1 and len(after_orders) == len(before_orders) + 1, repr(created)) + if len(created) == 1: + order = created[0]; number = order["order_number"] + store = row_dicts(after, "SELECT s.slug FROM stores s WHERE s.id=?", (order["store_id"],)) + judge.check("pickup_order_exact", order["fulfillment_method"] == "pickup" and store and store[0]["slug"] == STORE_SLUG and order["pickup_slot_label"] == SLOT_LABEL, repr(order)) + items = order_items(after, number) + judge.check("only_two_target_items", len(items) == 1 and items[0]["sku"] == SKU and items[0]["quantity"] == 2, repr(items)) + judge.check("answer_real_order_number", contains_all(answer, (number,)), repr(answer)) + removed_inventory, added_inventory = changed_rows(table_snapshot(initial, "store_inventory"), table_snapshot(after, "store_inventory")) + judge.check("inventory_decrement_exact", len(removed_inventory) == len(added_inventory) == 1 and removed_inventory[0][0] == added_inventory[0][0] and removed_inventory[0][3] - added_inventory[0][3] == 2, f"before={removed_inventory} after={added_inventory}") + removed_slots, added_slots = changed_rows(table_snapshot(initial, "pickup_slots"), table_snapshot(after, "pickup_slots")) + judge.check("slot_capacity_decrement_exact", len(removed_slots) == len(added_slots) == 1 and removed_slots[0][0] == added_slots[0][0] and removed_slots[0][5] - added_slots[0][5] == 1, f"before={removed_slots} after={added_slots}") + expected_tables = {"orders", "order_items", "payment_mocks", "reward_accounts", "reward_activities", "store_inventory", "pickup_slots"} + judge.check("only_expected_tables_changed", changed_tables(initial, after) == expected_tables, repr(changed_tables(initial, after))) + judge.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..06d477f9 --- /dev/null +++ b/sites/target/verify/verify_18.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +from verify_lib import ( + Judge, affirmative_contains, changed_tables, check_common, + clicked_transition, entered_text, final_answer, input_values, load_run, + login_submitted_as, parse_args, resolve_db, row_dicts, + submitted_from_path, visited_in_order, +) + +TASK_ID = "Target--18" +EMAIL = "bob.c@test.com" +SUBJECT = "Order arrived damaged" + + +def main() -> None: + args = parse_args(); trajectory = load_run(args.run_dir); answer = final_answer(trajectory); judge = Judge(TASK_ID) + check_common(judge, trajectory, TASK_ID) + judge.check("login_as_bob", login_submitted_as(trajectory, EMAIL), EMAIL) + judge.check("ordered_support_flow", visited_in_order(trajectory, [("/login", {}), ("/support", {}), ("/support/contact", {}), ("/account/support", {})]), "login, support, contact, history") + judge.check("clicked_contact_action", clicked_transition(trajectory, "/support", "/support/contact"), "contact form opened from Support") + values = input_values(trajectory, "/support/contact") + description_present = any(len(value.strip()) >= 10 and value not in {SUBJECT, "Email"} for value in values) + judge.check("required_form_values_entered", entered_text(trajectory, SUBJECT, "/support/contact") and entered_text(trajectory, "Email", "/support/contact") and description_present, repr(values)) + judge.check("support_form_submitted", submitted_from_path(trajectory, "/support/contact", "/account/support"), "contact form submission") + initial = resolve_db(args.initial_db, args.container, "instance_seed"); after = resolve_db(args.after_db, args.container, "instance") + readable = bool(initial and after); judge.check("databases_readable", readable, f"initial={initial} after={after}") + if readable: + before = row_dicts(initial, "SELECT t.* FROM support_tickets t JOIN users u ON u.id=t.user_id WHERE lower(u.email)=lower(?) ORDER BY t.id", (EMAIL,)); now = row_dicts(after, "SELECT t.* FROM support_tickets t JOIN users u ON u.id=t.user_id WHERE lower(u.email)=lower(?) ORDER BY t.id", (EMAIL,)); before_ids = {row["id"] for row in before}; created = [row for row in now if row["id"] not in before_ids] + exact = len(created) == 1 and created[0]["subject"] == SUBJECT and created[0]["channel"] == "Email" and created[0]["status"] == "Open" and len(created[0]["summary"].strip()) >= 10 + judge.check("one_exact_new_ticket", exact and len(now) == len(before) + 1 and all(row in now for row in before), repr(created)) + judge.check("only_support_tickets_changed", changed_tables(initial, after) == {"support_tickets"}, repr(changed_tables(initial, after))) + judge.check("answer_open_status", affirmative_contains(answer, "Open"), repr(answer)) + judge.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..11f570c7 --- /dev/null +++ b/sites/target/verify/verify_19.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +from verify_lib import ( + Judge, changed_tables, check_common, clicked_transition, contains_all, + entered_text, final_answer, input_values, load_run, login_submitted_as, + parse_args, resolve_db, row_dicts, submitted_from_path, + visited_in_order, +) + +TASK_ID = "Target--19" +EMAIL = "carol.d@test.com" +SKU = "TGT92595737" +PRODUCT_PATH = f"/product/{SKU}" +HEADLINE = "Great sound for the size" + + +def main() -> None: + args = parse_args(); trajectory = load_run(args.run_dir); answer = final_answer(trajectory); judge = Judge(TASK_ID) + check_common(judge, trajectory, TASK_ID) + judge.check("login_as_carol", login_submitted_as(trajectory, EMAIL), EMAIL) + judge.check("ordered_review_flow", visited_in_order(trajectory, [("/login", {}), (PRODUCT_PATH, {})]), "login before product review") + judge.check("product_opened_from_search", clicked_transition(trajectory, "/search", PRODUCT_PATH), "product clicked from search") + values = input_values(trajectory, PRODUCT_PATH); body_present = any(len(value.strip()) >= 15 and value not in {HEADLINE, "4"} for value in values) + rating_entered = entered_text(trajectory, "4", PRODUCT_PATH) or entered_text(trajectory, "4 stars", PRODUCT_PATH) + judge.check("review_fields_entered", entered_text(trajectory, HEADLINE, PRODUCT_PATH) and rating_entered and body_present, repr(values)) + judge.check("review_form_submitted", submitted_from_path(trajectory, PRODUCT_PATH, PRODUCT_PATH), "review submitted") + initial = resolve_db(args.initial_db, args.container, "instance_seed"); after = resolve_db(args.after_db, args.container, "instance") + readable = bool(initial and after); judge.check("databases_readable", readable, f"initial={initial} after={after}") + if readable: + before = row_dicts(initial, "SELECT r.* FROM reviews r JOIN products p ON p.id=r.product_id WHERE p.sku=? ORDER BY r.id", (SKU,)); now = row_dicts(after, "SELECT r.* FROM reviews r JOIN products p ON p.id=r.product_id WHERE p.sku=? ORDER BY r.id", (SKU,)); before_ids = {row["id"] for row in before}; created = [row for row in now if row["id"] not in before_ids] + exact = len(created) == 1 and created[0]["title"] == HEADLINE and created[0]["rating"] == 4 and created[0]["author_name"] == "Carol Diaz" and len(created[0]["body"].strip()) >= 15 + judge.check("one_exact_new_review", exact and len(now) == len(before) + 1 and all(row in now for row in before), repr(created)) + product_before = row_dicts(initial, "SELECT rating,review_count FROM products WHERE sku=?", (SKU,))[0]; product_after = row_dicts(after, "SELECT rating,review_count FROM products WHERE sku=?", (SKU,))[0] + expected_rating = round(((product_before["rating"] * product_before["review_count"]) + 4) / (product_before["review_count"] + 1), 1) + judge.check("aggregate_rating_updated", product_after["review_count"] == product_before["review_count"] + 1 and abs(product_after["rating"] - expected_rating) < 0.001, f"before={product_before} after={product_after}") + judge.check("only_reviews_and_product_changed", changed_tables(initial, after) == {"reviews", "products"}, repr(changed_tables(initial, after))) + judge.check("answer_confirms_headline", contains_all(answer, (HEADLINE,)), repr(answer)) + judge.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..8ee0565c --- /dev/null +++ b/sites/target/verify/verify_2.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +from verify_lib import ( + Judge, check_common, clicked_transition, contains_any, database_unchanged, + final_answer, load_run, number_bound_to, parse_args, resolve_db, + visited_in_order, +) + +TASK_ID = "Target--2" +SKU = "TGT94764181" +PRODUCT_PATH = f"/product/{SKU}" + + +def main() -> None: + args = parse_args(); trajectory = load_run(args.run_dir); answer = final_answer(trajectory); judge = Judge(TASK_ID) + check_common(judge, trajectory, TASK_ID) + judge.check("ordered_grocery_to_product", visited_in_order(trajectory, [("/category/grocery", {}), (PRODUCT_PATH, {})]), "Grocery precedes product") + judge.check("clicked_product_from_grocery", clicked_transition(trajectory, "/category/grocery", PRODUCT_PATH), "product opened from Grocery") + answer_ok = number_bound_to(answer, 610, ("sodium",)) and contains_any(answer, ("mg", "milligram", "milligrams")) + judge.check("answer_sodium_per_serving", answer_ok, repr(answer)) + initial = resolve_db(args.initial_db, args.container, "instance_seed"); after = resolve_db(args.after_db, args.container, "instance") + judge.check("read_only_database_unchanged", database_unchanged(initial, after), "complete database comparison") + judge.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..1652e7d9 --- /dev/null +++ b/sites/target/verify/verify_3.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +from verify_lib import ( + Judge, check_common, claims_relation, clicked_transition, contains_any, database_unchanged, + final_answer, load_run, number_bound_to, parse_args, resolve_db, + visited_in_order, +) + +TASK_ID = "Target--3" +PEPPERONI = "TGT13376389" +FOUR_CHEESE = "TGT13334000" +PEPPERONI_PATH = f"/product/{PEPPERONI}" +FOUR_CHEESE_PATH = f"/product/{FOUR_CHEESE}" + + +def main() -> None: + args = parse_args(); trajectory = load_run(args.run_dir); answer = final_answer(trajectory); judge = Judge(TASK_ID) + check_common(judge, trajectory, TASK_ID) + search_to_pepperoni = visited_in_order(trajectory, [("/search", {"q": "Red Baron"}), (PEPPERONI_PATH, {})]) + search_to_cheese = visited_in_order(trajectory, [("/search", {"q": "Red Baron"}), (FOUR_CHEESE_PATH, {})]) + judge.check("search_precedes_both_products", search_to_pepperoni and search_to_cheese, "Red Baron search before both details") + judge.check("clicked_both_product_results", clicked_transition(trajectory, "/search", PEPPERONI_PATH) and clicked_transition(trajectory, "/search", FOUR_CHEESE_PATH), "both products clicked from results") + judge.check("pepperoni_value_bound", number_bound_to(answer, 790, ("pepperoni",)), repr(answer)) + judge.check("four_cheese_value_bound", number_bound_to(answer, 710, ("four cheese",)), repr(answer)) + judge.check("four_cheese_identified_lower", claims_relation(answer, ("four cheese",), ("pepperoni",), ("less sodium", "lower", "less")), repr(answer)) + judge.check("answer_uses_sodium_unit", contains_any(answer, ("mg", "milligram", "milligrams")), repr(answer)) + initial = resolve_db(args.initial_db, args.container, "instance_seed"); after = resolve_db(args.after_db, args.container, "instance") + judge.check("read_only_database_unchanged", database_unchanged(initial, after), "complete database comparison") + judge.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..27ce2f5d --- /dev/null +++ b/sites/target/verify/verify_4.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +from verify_lib import ( + Judge, affirmative_contains, check_common, clicked_transition, contains_any, + database_unchanged, final_answer, load_run, number_bound_to, parse_args, + resolve_db, visited_in_order, +) + +TASK_ID = "Target--4" +SKU = "TGT91986267" +PRODUCT_PATH = f"/product/{SKU}" + + +def main() -> None: + args = parse_args(); trajectory = load_run(args.run_dir); answer = final_answer(trajectory); judge = Judge(TASK_ID) + check_common(judge, trajectory, TASK_ID) + judge.check("ordered_search_to_product", visited_in_order(trajectory, [("/search", {}), (PRODUCT_PATH, {})]), "search precedes product") + judge.check("clicked_product_result", clicked_transition(trajectory, "/search", PRODUCT_PATH), "product opened from results") + judge.check("recommendation_percent_bound", number_bound_to(answer, 74, ("recommend", "guests")), repr(answer)) + judge.check("highest_attribute", affirmative_contains(answer, "easy to clean"), repr(answer)) + judge.check("answer_uses_percentage_unit", contains_any(answer, ("%", "percent", "percentage")), repr(answer)) + initial = resolve_db(args.initial_db, args.container, "instance_seed"); after = resolve_db(args.after_db, args.container, "instance") + judge.check("read_only_database_unchanged", database_unchanged(initial, after), "complete database comparison") + judge.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..bcad7100 --- /dev/null +++ b/sites/target/verify/verify_5.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +from verify_lib import ( + Judge, affirmative_contains, check_common, clicked_transition, contains_all, + database_unchanged, final_answer, has_number, load_run, + parse_args, resolve_db, visited_in_order, visited_query, +) + +TASK_ID = "Target--5" +SKU = "TGT94760871" +PRODUCT_PATH = f"/product/{SKU}" + + +def main() -> None: + args = parse_args(); trajectory = load_run(args.run_dir); answer = final_answer(trajectory); judge = Judge(TASK_ID) + check_common(judge, trajectory, TASK_ID) + filtered = visited_query(trajectory, "/category/electronics", {"brand": "sony"}) + judge.check("electronics_sony_filter", filtered, "Electronics listing with Sony brand") + judge.check("filtered_listing_precedes_product", visited_in_order(trajectory, [("/category/electronics", {"brand": "sony"}), (PRODUCT_PATH, {})]), "filtered listing before detail") + judge.check("clicked_product_from_electronics", clicked_transition(trajectory, "/category/electronics", PRODUCT_PATH), "product opened from listing") + difference = has_number(answer, 22.95) + both_prices = has_number(answer, 36.72) and has_number(answer, 59.67) + judge.check("answer_price_comparison", difference or both_prices, repr(answer)) + plan_named = contains_all(answer, ("3-year",)) or contains_all(answer, ("longer plan",)) + coverage = plan_named and affirmative_contains(answer, "accidental") + judge.check("three_year_accidental_coverage", coverage, repr(answer)) + initial = resolve_db(args.initial_db, args.container, "instance_seed"); after = resolve_db(args.after_db, args.container, "instance") + judge.check("read_only_database_unchanged", database_unchanged(initial, after), "complete database comparison") + judge.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..1e1e54ca --- /dev/null +++ b/sites/target/verify/verify_6.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +from verify_lib import ( + Judge, check_common, contains_all, database_unchanged, final_answer, + has_money, load_run, parse_args, resolve_db, visited_query, +) + +TASK_ID = "Target--6" +EXPECTED_NAME_TOKENS = ("Cuddler Dog Bed", "Blue", "Boots & Barkley") +EXPECTED_PRICE = 24.99 +FILTERS = {"brand": "boots-barkley", "availability": "pickup", "deals": "1", "sort": "price-asc"} + + +def main() -> None: + args = parse_args(); trajectory = load_run(args.run_dir); answer = final_answer(trajectory); judge = Judge(TASK_ID) + check_common(judge, trajectory, TASK_ID) + judge.check("all_filters_on_one_pets_url", visited_query(trajectory, "/category/pets", FILTERS), repr(FILTERS)) + judge.check("answer_first_product_name", contains_all(answer, EXPECTED_NAME_TOKENS), repr(answer)) + judge.check("answer_first_product_price", has_money(answer, EXPECTED_PRICE), repr(answer)) + initial = resolve_db(args.initial_db, args.container, "instance_seed"); after = resolve_db(args.after_db, args.container, "instance") + judge.check("read_only_database_unchanged", database_unchanged(initial, after), "complete database comparison") + judge.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..e88c2d84 --- /dev/null +++ b/sites/target/verify/verify_7.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +from verify_lib import ( + Judge, check_common, claims_relation, clicked_transition, contains_any, database_unchanged, + final_answer, has_number, load_run, number_bound_to, parse_args, resolve_db, + submitted_from_path, visited_in_order, visited_path, +) + +TASK_ID = "Target--7" +SUPREME = "TGT13333997" +PEPPERONI = "TGT31168522" +SUPREME_PATH = f"/product/{SUPREME}" +PEPPERONI_PATH = f"/product/{PEPPERONI}" + + +def main() -> None: + args = parse_args(); trajectory = load_run(args.run_dir); answer = final_answer(trajectory); judge = Judge(TASK_ID) + check_common(judge, trajectory, TASK_ID) + judge.check("search_precedes_products", visited_in_order(trajectory, [("/search", {"q": "Red Baron"}), (SUPREME_PATH, {}), ("/compare", {})]) and visited_in_order(trajectory, [("/search", {"q": "Red Baron"}), (PEPPERONI_PATH, {}), ("/compare", {})]), "search, details, compare") + judge.check("clicked_both_results", clicked_transition(trajectory, "/search", SUPREME_PATH) and clicked_transition(trajectory, "/search", PEPPERONI_PATH), "both details opened from results") + judge.check("submitted_both_compare_controls", submitted_from_path(trajectory, SUPREME_PATH, SUPREME_PATH) and submitted_from_path(trajectory, PEPPERONI_PATH, PEPPERONI_PATH), "both detail-page compare forms submitted") + judge.check("opened_compare_page", visited_path(trajectory, "/compare"), "Compare visited") + values = (number_bound_to(answer, 650, ("supreme",)) and number_bound_to(answer, 810, ("pepperoni", "brick oven"))) or has_number(answer, 160) + judge.check("answer_values_or_difference", values, repr(answer)) + judge.check("supreme_identified_lower", claims_relation(answer, ("supreme",), ("pepperoni",), ("less sodium", "lower", "less")), repr(answer)) + judge.check("answer_uses_sodium_unit", contains_any(answer, ("mg", "milligram", "milligrams")), repr(answer)) + initial = resolve_db(args.initial_db, args.container, "instance_seed"); after = resolve_db(args.after_db, args.container, "instance") + judge.check("database_unchanged", database_unchanged(initial, after), "anonymous compare is session-only") + judge.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..202fd44b --- /dev/null +++ b/sites/target/verify/verify_8.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +from verify_lib import ( + Judge, check_common, clicked_transition, contains_all, database_unchanged, + final_answer, has_money, load_run, login_submitted_as, parse_args, + resolve_db, visited_in_order, +) + +TASK_ID = "Target--8" +EMAIL = "david.k@test.com" + + +def main() -> None: + args = parse_args(); trajectory = load_run(args.run_dir); answer = final_answer(trajectory); judge = Judge(TASK_ID) + check_common(judge, trajectory, TASK_ID) + judge.check("login_as_david", login_submitted_as(trajectory, EMAIL), EMAIL) + judge.check("ordered_login_to_orders", visited_in_order(trajectory, [("/login", {}), ("/account", {}), ("/account/orders", {})]), "login, account, orders") + judge.check("clicked_order_history", clicked_transition(trajectory, "/account", "/account/orders"), "Orders opened from account") + judge.check("answer_processing_order", contains_all(answer, ("TGT-240013",)) and has_money(answer, 32.61), repr(answer)) + initial = resolve_db(args.initial_db, args.container, "instance_seed"); after = resolve_db(args.after_db, args.container, "instance") + judge.check("read_only_database_unchanged", database_unchanged(initial, after), "complete database comparison") + judge.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..00962c0c --- /dev/null +++ b/sites/target/verify/verify_9.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +from verify_lib import ( + Judge, check_common, clicked_transition, database_unchanged, final_answer, + load_run, login_submitted_as, number_bound_to, parse_args, resolve_db, + visited_in_order, +) + +TASK_ID = "Target--9" +EMAIL = "david.k@test.com" + + +def main() -> None: + args = parse_args(); trajectory = load_run(args.run_dir); answer = final_answer(trajectory); judge = Judge(TASK_ID) + check_common(judge, trajectory, TASK_ID) + judge.check("login_as_david", login_submitted_as(trajectory, EMAIL), EMAIL) + judge.check("ordered_login_to_rewards", visited_in_order(trajectory, [("/login", {}), ("/account", {}), ("/account/rewards", {})]), "login, account, rewards") + judge.check("clicked_rewards_dashboard", clicked_transition(trajectory, "/account", "/account/rewards"), "Rewards opened from account") + judge.check("answer_points_balance", number_bound_to(answer, 2185, ("point", "points", "balance")), repr(answer)) + initial = resolve_db(args.initial_db, args.container, "instance_seed"); after = resolve_db(args.after_db, args.container, "instance") + judge.check("read_only_database_unchanged", database_unchanged(initial, after), "complete database comparison") + judge.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..71731464 --- /dev/null +++ b/sites/target/verify/verify_lib.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python3 +"""Shared deterministic utilities for Target task verifiers.""" + +from __future__ import annotations + +import argparse +import ipaddress +import json +import os +import re +import sqlite3 +import subprocess +import tempfile +import unicodedata +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable, Sequence +from urllib.parse import parse_qs, urlparse + +SITE = "target" +DEFAULT_CONTAINER = os.environ.get("WH_CONTAINER", "wh-review") + + +@dataclass(frozen=True) +class VerifyArgs: + run_dir: str + initial_db: str | None + after_db: str | None + container: str + no_llm: bool + + +def _bool_value(value: str) -> bool: + return str(value).casefold() in {"1", "true", "yes", "on"} + + +def parse_args() -> VerifyArgs: + parser = argparse.ArgumentParser() + parser.add_argument("--run_dir", required=True) + parser.add_argument("--initial_db") + parser.add_argument("--after_db") + parser.add_argument("--container", default=DEFAULT_CONTAINER) + parser.add_argument("--no_llm", nargs="?", const=True, default=False, type=_bool_value) + args = parser.parse_args() + run_dir = Path(args.run_dir) + initial_snapshot = run_dir / "initial.db" + after_snapshot = run_dir / "after.db" + return VerifyArgs( + run_dir=args.run_dir, + initial_db=args.initial_db or (str(initial_snapshot) if initial_snapshot.is_file() else None), + after_db=args.after_db or (str(after_snapshot) if after_snapshot.is_file() else None), + container=args.container, + no_llm=bool(args.no_llm), + ) + + +def load_run(run_dir: str | os.PathLike[str]) -> dict[str, Any]: + path = Path(run_dir) / "trajectory.json" + trajectory = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(trajectory, dict): + raise ValueError("trajectory.json must contain a JSON object") + return trajectory + + +def normalize_text(value: Any) -> str: + text = unicodedata.normalize("NFKC", str(value or "")) + text = text.replace("’", "'").replace("“", '"').replace("”", '"') + return re.sub(r"\s+", " ", text).strip().casefold() + + +def final_answer(trajectory: dict[str, Any]) -> str: + return str(trajectory.get("final_answer") or "").strip() + + +def trajectory_urls(trajectory: dict[str, Any]) -> list[str]: + urls = [] + if trajectory.get("start_url"): + urls.append(str(trajectory["start_url"])) + for step in trajectory.get("steps") or []: + if not isinstance(step, dict): + continue + for key in ("url_before", "url", "url_after"): + value = str(step.get(key) or "") + if value and (not urls or value != urls[-1]): + urls.append(value) + if trajectory.get("final_url") and str(trajectory["final_url"]) != (urls[-1] if urls else ""): + urls.append(str(trajectory["final_url"])) + return urls + + +def _is_loopback(hostname: str) -> bool: + if hostname.casefold() == "localhost": + return True + try: + return ipaddress.ip_address(hostname).is_loopback + except ValueError: + return False + + +def is_target_url(url: str, trajectory: dict[str, Any]) -> bool: + parsed = urlparse(str(url or "")) + start = urlparse(str(trajectory.get("start_url") or "")) + if parsed.scheme not in {"http", "https"} or not parsed.hostname or not start.hostname: + return False + return ( + _is_loopback(parsed.hostname) + and _is_loopback(start.hostname) + and parsed.scheme == start.scheme + and parsed.port == start.port + ) + + +def normalized_path(url: str) -> str: + path = urlparse(str(url or "")).path or "/" + return path.rstrip("/") or "/" + + +def visited_path(trajectory: dict[str, Any], path: str) -> bool: + expected = normalized_path(path) + return any( + is_target_url(url, trajectory) and normalized_path(url) == expected + for url in trajectory_urls(trajectory) + ) + + +def query_matches(url: str, expected: dict[str, str]) -> bool: + params = parse_qs(urlparse(url).query) + return all( + normalize_text((params.get(key) or [""])[0]) == normalize_text(value) + for key, value in expected.items() + ) + + +def visited_query(trajectory: dict[str, Any], path: str, expected: dict[str, str]) -> bool: + return any( + is_target_url(url, trajectory) + and normalized_path(url) == normalized_path(path) + and query_matches(url, expected) + for url in trajectory_urls(trajectory) + ) + + +def visited_in_order(trajectory: dict[str, Any], requirements: list[tuple[str, dict[str, str]]]) -> bool: + urls = trajectory_urls(trajectory) + cursor = 0 + for path, query in requirements: + found = False + for index in range(cursor, len(urls)): + url = urls[index] + if is_target_url(url, trajectory) and normalized_path(url) == normalized_path(path) and query_matches(url, query): + cursor = index + 1 + found = True + break + if not found: + return False + return True + + +def transition_pairs(trajectory: dict[str, Any]): + steps = trajectory.get("steps") or [] + for index, step in enumerate(steps): + if not isinstance(step, dict): + continue + current = str(step.get("url") or step.get("url_before") or "") + if not is_target_url(current, trajectory): + continue + candidates = [] + if step.get("url_after"): + candidates.append(str(step["url_after"])) + elif index + 1 < len(steps) and isinstance(steps[index + 1], dict): + candidates.append(str(steps[index + 1].get("url") or steps[index + 1].get("url_after") or "")) + for following in candidates: + if is_target_url(following, trajectory): + yield normalize_text(step.get("action")), current, following + + +def clicked_transition(trajectory: dict[str, Any], from_path: str, to_path: str, to_query: dict[str, str] | None = None) -> bool: + expected_query = to_query or {} + for action, current, following in transition_pairs(trajectory): + if action != "click" or normalized_path(current) != normalized_path(from_path): + continue + if normalized_path(following) == normalized_path(to_path) and query_matches(following, expected_query): + return True + return False + + +def submitted_from_path(trajectory: dict[str, Any], path: str, destination: str | None = None) -> bool: + for action, current, following in transition_pairs(trajectory): + if action != "click" or normalized_path(current) != normalized_path(path): + continue + if destination is None or normalized_path(following) == normalized_path(destination): + return True + return False + + +def input_values(trajectory: dict[str, Any], path: str | None = None) -> list[str]: + values = [] + for step in trajectory.get("steps") or []: + if not isinstance(step, dict) or normalize_text(step.get("action")) not in {"input", "fill", "type", "select"}: + continue + url = str(step.get("url") or step.get("url_before") or "") + if not is_target_url(url, trajectory): + continue + if path is not None and normalized_path(url) != normalized_path(path): + continue + params = step.get("params") or {} + value = params.get("text", params.get("value", params.get("option", params.get("label")))) if isinstance(params, dict) else None + if value is not None: + values.append(str(value)) + return values + + +def entered_text(trajectory: dict[str, Any], expected: str, path: str | None = None) -> bool: + expected_normalized = normalize_text(expected) + return any(normalize_text(value) == expected_normalized for value in input_values(trajectory, path)) + + +def last_entered_email(trajectory: dict[str, Any], path: str = "/login") -> str: + emails = [normalize_text(value) for value in input_values(trajectory, path) if re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", value.strip())] + return emails[-1] if emails else "" + + +def login_submitted_as(trajectory: dict[str, Any], email: str) -> bool: + return ( + visited_path(trajectory, "/login") + and last_entered_email(trajectory) == normalize_text(email) + and entered_text(trajectory, "TestPass123!", "/login") + and submitted_from_path(trajectory, "/login") + ) + + +NEGATION_WORDS = {"not", "no", "never", "without", "isn't", "isnt", "wasn't", "wasnt", "doesn't", "doesnt", "didn't", "didnt"} + + +def _negated_at(text: str, start: int) -> bool: + clause = re.split(r"[.!?;:\n]+|\b(?:and|but|however|instead)\b", text[:start])[-1] + words = re.findall(r"[a-z0-9]+(?:['’][a-z]+)?", clause) + return any(word in NEGATION_WORDS for word in words) + + +def _denied_after(text: str, end: int) -> bool: + suffix = re.sub(r"^\s*[-—–,:;!?]*\s*", "", text[end:]) + return re.match(r"(?:(?:is|was|does|did|are|were)\s+)?(?:not|never|no)\b|(?:isn't|isnt|wasn't|wasnt|doesn't|doesnt|didn't|didnt|aren't|arent|weren't|werent)\b", suffix) is not None + + +def affirmative_contains(text: Any, expected: Any) -> bool: + normalized = normalize_text(text) + needle = normalize_text(expected) + matches = list(re.finditer(re.escape(needle), normalized)) + if not needle or not matches: + return False + match = matches[-1] + return not _negated_at(normalized, match.start()) and not _denied_after(normalized, match.end()) + + +def contains_all(text: Any, expected: Iterable[Any]) -> bool: + return all(affirmative_contains(text, value) for value in expected) + + +def contains_any(text: Any, expected: Iterable[Any]) -> bool: + return any(affirmative_contains(text, value) for value in expected) + + +def number_matches(text: Any, value: float, tolerance: float = 0.005) -> list[re.Match[str]]: + normalized = normalize_text(text) + matches = [] + for match in re.finditer(r"(? bool: + return bool(number_matches(text, value, tolerance)) + + +def has_money(text: Any, amount: float) -> bool: + return has_number(text, round(float(amount), 2), 0.005) + + +def number_bound_to(text: Any, value: float, labels: Sequence[str], distance: int = 120) -> bool: + normalized = normalize_text(text) + for match in number_matches(normalized, value): + left = max(0, match.start() - distance) + right = min(len(normalized), match.end() + distance) + window = normalized[left:right] + if any(normalize_text(label) in window for label in labels): + return True + return False + + +def claims_relation(text: Any, winner_labels: Sequence[str], loser_labels: Sequence[str], relation_words: Sequence[str]) -> bool: + normalized = normalize_text(text) + if not any(affirmative_contains(normalized, label) for label in winner_labels): + return False + winner_positions = [normalized.rfind(normalize_text(label)) for label in winner_labels if normalize_text(label) in normalized] + loser_positions = [normalized.rfind(normalize_text(label)) for label in loser_labels if normalize_text(label) in normalized] + relation_positions = [normalized.rfind(normalize_text(word)) for word in relation_words if normalize_text(word) in normalized] + if not winner_positions or not relation_positions: + return False + winner = max(winner_positions) + relation = min(relation_positions, key=lambda position: abs(position - winner)) + if abs(relation - winner) > 160 or _negated_at(normalized, relation): + return False + if loser_positions: + loser = min(loser_positions, key=lambda position: abs(position - relation)) + if loser < relation < winner and any(word in normalized[loser:winner] for word in ("less", "lower", "lowest", "higher")): + return False + return True + + +def fetch_db(container: str, kind: str) -> str: + if kind not in {"instance", "instance_seed"}: + raise ValueError(f"unsupported DB kind: {kind}") + handle, destination = tempfile.mkstemp(prefix=f"target_{kind}_", suffix=".db") + os.close(handle) + source = f"{container}:/opt/WebSyn/{SITE}/{kind}/{SITE}.db" + result = subprocess.run(["docker", "cp", source, destination], capture_output=True, text=True, check=False) + if result.returncode: + Path(destination).unlink(missing_ok=True) + raise RuntimeError(result.stderr.strip() or f"could not copy {source}") + return destination + + +def resolve_db(explicit: str | None, container: str, kind: str) -> str | None: + if explicit: + return explicit if Path(explicit).is_file() else None + try: + return fetch_db(container, kind) + except (OSError, RuntimeError): + return None + + +def db_query(path: str, sql: str, params: Sequence[Any] = ()) -> list[sqlite3.Row]: + connection = sqlite3.connect(path) + connection.row_factory = sqlite3.Row + try: + return connection.execute(sql, params).fetchall() + finally: + connection.close() + + +def table_snapshot(path: str, table: str) -> list[tuple[Any, ...]]: + rows = db_query(path, f'SELECT * FROM "{table}" ORDER BY rowid') + return [tuple(row) for row in rows] + + +def database_tables(path: str) -> list[str]: + rows = db_query(path, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name") + return [str(row["name"]) for row in rows] + + +def changed_tables(initial_db: str, after_db: str) -> set[str]: + initial_tables = database_tables(initial_db) + after_tables = database_tables(after_db) + if initial_tables != after_tables: + return {""} + return {table for table in initial_tables if table_snapshot(initial_db, table) != table_snapshot(after_db, table)} + + +def database_unchanged(initial_db: str | None, after_db: str | None) -> bool: + return bool(initial_db and after_db and not changed_tables(initial_db, after_db)) + + +def row_dicts(path: str, sql: str, params: Sequence[Any] = ()) -> list[dict[str, Any]]: + return [dict(row) for row in db_query(path, sql, params)] + + +def user_id(path: str, email: str) -> int | None: + rows = db_query(path, "SELECT id FROM users WHERE lower(email)=lower(?)", (email,)) + return int(rows[0]["id"]) if rows else None + + +def cart_snapshot(path: str, email: str) -> list[dict[str, Any]]: + return row_dicts(path, "SELECT c.id,p.sku,c.quantity,c.fulfillment_method,c.store_id,c.delivery_option_id,c.protection_plan_id,c.created_at FROM cart_items c JOIN users u ON u.id=c.user_id JOIN products p ON p.id=c.product_id WHERE lower(u.email)=lower(?) ORDER BY c.id", (email,)) + + +def wishlist_snapshot(path: str, email: str) -> list[dict[str, Any]]: + return row_dicts(path, "SELECT w.id,p.sku,w.created_at FROM wishlist_items w JOIN users u ON u.id=w.user_id JOIN products p ON p.id=w.product_id WHERE lower(u.email)=lower(?) ORDER BY w.id", (email,)) + + +def orders_snapshot(path: str, email: str) -> list[dict[str, Any]]: + return row_dicts(path, "SELECT o.* FROM orders o JOIN users u ON u.id=o.user_id WHERE lower(u.email)=lower(?) ORDER BY o.id", (email,)) + + +def order_items(path: str, order_number: str) -> list[dict[str, Any]]: + return row_dicts(path, "SELECT oi.*,p.sku 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,)) + + +def check_common(judge: "Judge", trajectory: dict[str, Any], task_id: str) -> None: + judge.check("task_id_matches", str(trajectory.get("task_id") or "") == task_id, f"observed={trajectory.get('task_id')!r}") + judge.check("final_answer_nonempty", bool(final_answer(trajectory)), repr(final_answer(trajectory))) + judge.check("start_url_is_target", is_target_url(str(trajectory.get("start_url") or ""), trajectory), f"start_url={trajectory.get('start_url')!r}") + + +class Judge: + def __init__(self, task_id: str): + self.task_id = task_id + self.passed = True + self.reason = "" + self.evidence: list[str] = [] + + def check(self, name: str, condition: bool, evidence: str = "") -> bool: + self.evidence.append(f"[{'PASS' if condition else 'FAIL'}] {name}: {evidence}") + if not condition: + self.passed = False + if not self.reason: + self.reason = name + return bool(condition) + + def emit(self) -> None: + print(json.dumps({"task_id": self.task_id, "pass": self.passed, "reason": self.reason, "evidence": self.evidence}, ensure_ascii=False, indent=2)) + raise SystemExit(0 if self.passed else 1) + + +def fail_closed(task_id: str, reason: str, detail: str) -> None: + print(json.dumps({"task_id": task_id, "pass": False, "reason": reason, "infra_error": True, "evidence": [f"[FAIL] {reason}: {detail}"]}, ensure_ascii=False, indent=2)) + raise SystemExit(1) diff --git a/websyn_start.sh b/websyn_start.sh index b3b5a161..c347ccec 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 ikea phys_org) + cambridge_dictionary coursera espn merriam_webster ikea phys_org target) BASE_PORT=40000 PID_DIR=/tmp/websyn_pids mkdir -p "$PID_DIR"