From 4fe15e875888d7bbd8a87710245692c05003f122 Mon Sep 17 00:00:00 2001 From: evanz37 <89922710+evanz37@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:57:04 -0400 Subject: [PATCH 01/21] feat(webmd_doctor): add WebMD Doctor mirror (site 24, port 40024) Offline Flask mirror of https://doctor.webmd.com/ ("WebMD Care"): deterministic synthetic directory of 224 doctors across 10 specialties and 8 cities around Newark, DE 19711, with hospitals, group practices, Choice Awards, real auth, saved providers, appointment requests and pending reviews. - Build-generated seed (.build-generated-seed): the Dockerfile regenerates instance_seed/webmd_doctor.db plus 224 Pillow initials avatars and 90 video poster frames from seed_data.py; no Hugging Face assets, .assets-revision untouched. - One seeded RNG (20260910), literal reference date 2026-09-10, hardcoded scrypt hashes for the four benchmark users, whole-function seed gates, seed_metadata version + expected-count validation; byte-identical after /reset and docker restart. - Deterministic longest-match query parser (specialty / condition / procedure / insurer / gender / virtual), conjunctive filters, Best Match / Distance / Average Rating / Number of Ratings ranking, numbered pagination. - Registered as index 24 in websyn_start.sh and control_server.py; Dockerfile EXPOSE raised to 40024, header comment bumped to 25 sites. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GTvmqJcShyy3v3KfEPxMPe --- Dockerfile | 9 +- control_server.py | 1 + sites/webmd_doctor/.build-generated-seed | 1 + sites/webmd_doctor/.gitignore | 2 + sites/webmd_doctor/README.md | 32 + sites/webmd_doctor/_health.py | 24 + sites/webmd_doctor/app.py | 1865 +++++++++++++++++ sites/webmd_doctor/requirements.txt | 7 + sites/webmd_doctor/seed_data.py | 1323 ++++++++++++ sites/webmd_doctor/static/css/site.css | 502 +++++ .../static/fonts/source-sans-3-400.woff2 | Bin 0 -> 15684 bytes .../static/fonts/source-sans-3-600.woff2 | Bin 0 -> 15696 bytes .../static/fonts/source-sans-3-700.woff2 | Bin 0 -> 15640 bytes sites/webmd_doctor/static/icons/favicon.svg | 1 + .../static/icons/logo-webmd-care.svg | 3 + .../static/icons/social-facebook.svg | 1 + .../static/icons/social-instagram.svg | 1 + .../static/icons/social-pinterest.svg | 1 + .../static/icons/social-tiktok.svg | 1 + .../static/icons/social-whatsapp.svg | 1 + sites/webmd_doctor/static/icons/social-x.svg | 1 + .../static/icons/webmd-logo-white.svg | 4 + sites/webmd_doctor/static/js/site.js | 125 ++ sites/webmd_doctor/templates/404.html | 5 + sites/webmd_doctor/templates/500.html | 5 + sites/webmd_doctor/templates/_filter_bar.html | 81 + sites/webmd_doctor/templates/_footer.html | 54 + sites/webmd_doctor/templates/_header.html | 48 + sites/webmd_doctor/templates/_icons.html | 35 + sites/webmd_doctor/templates/_mini_card.html | 12 + sites/webmd_doctor/templates/_pagination.html | 12 + .../templates/_physician_card.html | 37 + sites/webmd_doctor/templates/_search_bar.html | 13 + sites/webmd_doctor/templates/_stars.html | 1 + .../templates/account_appointments.html | 22 + .../webmd_doctor/templates/account_saved.html | 15 + .../templates/award_recipients.html | 17 + sites/webmd_doctor/templates/awards.html | 39 + sites/webmd_doctor/templates/base.html | 29 + sites/webmd_doctor/templates/book.html | 45 + .../webmd_doctor/templates/book_confirm.html | 23 + sites/webmd_doctor/templates/doctor.html | 402 ++++ sites/webmd_doctor/templates/guidelines.html | 26 + sites/webmd_doctor/templates/hospital.html | 51 + sites/webmd_doctor/templates/hub_list.html | 51 + sites/webmd_doctor/templates/index.html | 91 + sites/webmd_doctor/templates/login.html | 26 + sites/webmd_doctor/templates/practice.html | 63 + sites/webmd_doctor/templates/results.html | 22 + sites/webmd_doctor/templates/signup.html | 26 + .../templates/specialty_city.html | 20 + .../templates/specialty_index.html | 16 + .../templates/specialty_landing.html | 42 + .../templates/specialty_state.html | 18 + websyn_start.sh | 3 +- 55 files changed, 5252 insertions(+), 3 deletions(-) create mode 100644 sites/webmd_doctor/.build-generated-seed create mode 100644 sites/webmd_doctor/.gitignore create mode 100644 sites/webmd_doctor/README.md create mode 100644 sites/webmd_doctor/_health.py create mode 100644 sites/webmd_doctor/app.py create mode 100644 sites/webmd_doctor/requirements.txt create mode 100644 sites/webmd_doctor/seed_data.py create mode 100644 sites/webmd_doctor/static/css/site.css create mode 100644 sites/webmd_doctor/static/fonts/source-sans-3-400.woff2 create mode 100644 sites/webmd_doctor/static/fonts/source-sans-3-600.woff2 create mode 100644 sites/webmd_doctor/static/fonts/source-sans-3-700.woff2 create mode 100644 sites/webmd_doctor/static/icons/favicon.svg create mode 100644 sites/webmd_doctor/static/icons/logo-webmd-care.svg create mode 100644 sites/webmd_doctor/static/icons/social-facebook.svg create mode 100644 sites/webmd_doctor/static/icons/social-instagram.svg create mode 100644 sites/webmd_doctor/static/icons/social-pinterest.svg create mode 100644 sites/webmd_doctor/static/icons/social-tiktok.svg create mode 100644 sites/webmd_doctor/static/icons/social-whatsapp.svg create mode 100644 sites/webmd_doctor/static/icons/social-x.svg create mode 100644 sites/webmd_doctor/static/icons/webmd-logo-white.svg create mode 100644 sites/webmd_doctor/static/js/site.js create mode 100644 sites/webmd_doctor/templates/404.html create mode 100644 sites/webmd_doctor/templates/500.html create mode 100644 sites/webmd_doctor/templates/_filter_bar.html create mode 100644 sites/webmd_doctor/templates/_footer.html create mode 100644 sites/webmd_doctor/templates/_header.html create mode 100644 sites/webmd_doctor/templates/_icons.html create mode 100644 sites/webmd_doctor/templates/_mini_card.html create mode 100644 sites/webmd_doctor/templates/_pagination.html create mode 100644 sites/webmd_doctor/templates/_physician_card.html create mode 100644 sites/webmd_doctor/templates/_search_bar.html create mode 100644 sites/webmd_doctor/templates/_stars.html create mode 100644 sites/webmd_doctor/templates/account_appointments.html create mode 100644 sites/webmd_doctor/templates/account_saved.html create mode 100644 sites/webmd_doctor/templates/award_recipients.html create mode 100644 sites/webmd_doctor/templates/awards.html create mode 100644 sites/webmd_doctor/templates/base.html create mode 100644 sites/webmd_doctor/templates/book.html create mode 100644 sites/webmd_doctor/templates/book_confirm.html create mode 100644 sites/webmd_doctor/templates/doctor.html create mode 100644 sites/webmd_doctor/templates/guidelines.html create mode 100644 sites/webmd_doctor/templates/hospital.html create mode 100644 sites/webmd_doctor/templates/hub_list.html create mode 100644 sites/webmd_doctor/templates/index.html create mode 100644 sites/webmd_doctor/templates/login.html create mode 100644 sites/webmd_doctor/templates/practice.html create mode 100644 sites/webmd_doctor/templates/results.html create mode 100644 sites/webmd_doctor/templates/signup.html create mode 100644 sites/webmd_doctor/templates/specialty_city.html create mode 100644 sites/webmd_doctor/templates/specialty_index.html create mode 100644 sites/webmd_doctor/templates/specialty_landing.html create mode 100644 sites/webmd_doctor/templates/specialty_state.html diff --git a/Dockerfile b/Dockerfile index 86c17615..db903c66 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # WebHarbor — slim, self-contained image. -# 24 Flask mirror sites + control plane on :8101. +# 25 Flask mirror sites + control plane on :8101. FROM python:3.12-slim-bookworm @@ -50,6 +50,11 @@ RUN python3 /opt/check_asset_inventory.py /opt/WebSyn/walmart_careers && \ RUN cd /opt/WebSyn/walmart_careers && rm -rf instance instance_seed && \ PYTHONHASHSEED=0 python seed_data.py && rm -rf instance +# WebMD Doctor ships no HF assets: the deterministic SQLite seed, the Pillow +# initials avatars and the video poster frames are all generated here. +RUN cd /opt/WebSyn/webmd_doctor && rm -rf instance instance_seed && \ + PYTHONHASHSEED=0 python seed_data.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 @@ -72,6 +77,6 @@ os.makedirs('instance_seed', exist_ok=True); \ shutil.copy2('instance/rotten_tomatoes.db', 'instance_seed/rotten_tomatoes.db'); \ print('Rotten Tomatoes seed DB generated at build time.')" && rm -rf /opt/WebSyn/rotten_tomatoes/instance -EXPOSE 8101 40000-40023 +EXPOSE 8101 40000-40024 CMD ["/opt/websyn_start.sh"] diff --git a/control_server.py b/control_server.py index 7df0d9ee..245c2a9b 100644 --- a/control_server.py +++ b/control_server.py @@ -27,6 +27,7 @@ 'github', 'google_flights', 'google_map', 'google_search', 'huggingface', 'wolfram_alpha', 'cambridge_dictionary', 'coursera', 'espn', 'merriam_webster', 'ikea', 'phys_org', 'target', 'ted', 'osu', 'rotten_tomatoes', 'compass', 'walmart_careers', + 'webmd_doctor', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' diff --git a/sites/webmd_doctor/.build-generated-seed b/sites/webmd_doctor/.build-generated-seed new file mode 100644 index 00000000..c799e8e3 --- /dev/null +++ b/sites/webmd_doctor/.build-generated-seed @@ -0,0 +1 @@ +The Dockerfile generates instance_seed/webmd_doctor.db, static/images/avatars/ and static/images/posters/ deterministically from tracked source code (seed_data.py); this site has no Hugging Face assets. diff --git a/sites/webmd_doctor/.gitignore b/sites/webmd_doctor/.gitignore new file mode 100644 index 00000000..0dd3cf6e --- /dev/null +++ b/sites/webmd_doctor/.gitignore @@ -0,0 +1,2 @@ +# Local build/inspection helpers (ground truth, assertions, audits): never shipped with the site. +scripts_dev/ diff --git a/sites/webmd_doctor/README.md b/sites/webmd_doctor/README.md new file mode 100644 index 00000000..35c4018f --- /dev/null +++ b/sites/webmd_doctor/README.md @@ -0,0 +1,32 @@ +# WebMD Doctor mirror + +Offline Flask mirror of `https://doctor.webmd.com/` (branded "WebMD Care" upstream). In the 25-site registry it is site index 24 and runs on container port `40024`. Every doctor, practice, hospital, address, phone number, NPI, review and user account is deterministic synthetic benchmark data; only the site chrome mirrors upstream. + +## Runtime + +```bash +uv venv .venv --python 3.12 +uv pip install --python .venv/bin/python -r sites/webmd_doctor/requirements.txt +cd sites/webmd_doctor && PYTHONHASHSEED=0 ../../.venv/bin/python seed_data.py # writes instance_seed/, static/images/{avatars,posters}/ +PORT=40024 ../../.venv/bin/python app.py +``` + +The Docker build regenerates `instance_seed/webmd_doctor.db` plus the Pillow avatars (224) and video poster frames (90) from `seed_data.py`; the site ships no Hugging Face assets (`.build-generated-seed`). `seed_metadata` version `webmd-doctor-v1`, `EXPECTED_COUNTS` and a foreign-key check reject partial or incompatible state, and every seed function is gated as a whole so `/reset/webmd_doctor` and `docker restart` leave the DB byte-identical. + +## Seeded rows + +| Model | Rows | Model | Rows | +|---|---|---|---| +| doctors | 224 (200 within 40 mi of Newark, DE 19711 + 24 in Baltimore, MD) | locations | 345 | +| specialties | 10 | conditions / procedures / expertise_areas | 40 / 30 / 40 | +| doctor_conditions / doctor_procedures / doctor_expertise | 1619 / 1121 / 674 | insurers / insurance_plans / doctor_insurances | 12 / 28 / 2224 | +| cities / city_zips | 8 / 24 | hospitals / practices | 12 / 30 | +| reviews | 1208 | doctor_perspectives | 1568 | +| certifications / licenses / education | 286 / 301 / 573 | awards / doctor_languages | 50 / 327 | +| users | 4 | saved_providers / appointment_requests / user_reviews | 4 / 1 / 1 | + +Benchmark accounts: `alice.j`, `bob.c`, `carol.d`, `david.k` `@test.com`, password `TestPass123!`. + +## Routes + +`/`, `/results` (deterministic term parser + conjunctive filters, Best Match / Distance / Average Rating / Number of Ratings), `/doctor/-overview` (tab aliases 301), `/doctor//bookappointment` (Enhanced only, login required), `/doctor//save`, `/doctor//review`, `/providers/specialty[/[/[/]]]`, `/hospitals[/]`, `/hospital/`, `/grouppractices[/]`, `/practice/`, `/choice-awards`, `/choice-awards/awardrecipients?award-class=`, `/reviews-guidelines`, `/login`, `/signup`, `/logout` (POST), `/account/saved`, `/account/saved//remove`, `/account/appointments`, `/health`. diff --git a/sites/webmd_doctor/_health.py b/sites/webmd_doctor/_health.py new file mode 100644 index 00000000..716820c0 --- /dev/null +++ b/sites/webmd_doctor/_health.py @@ -0,0 +1,24 @@ +"""Per-site health probe (optional, called by control_server). + +Reports row counts only; never exposes record content. +""" +import os +import sqlite3 + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DB_PATH = os.path.join(BASE_DIR, "instance", "webmd_doctor.db") +TABLES = ("doctors", "specialties", "hospitals", "practices", "users") + + +def health(): + counts = {} + try: + connection = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True) + try: + for table in TABLES: + counts[table] = connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] + finally: + connection.close() + except sqlite3.Error: + return {"ok": False, "site": "webmd_doctor", "counts": counts} + return {"ok": counts.get("doctors", 0) > 0, "site": "webmd_doctor", "counts": counts} diff --git a/sites/webmd_doctor/app.py b/sites/webmd_doctor/app.py new file mode 100644 index 00000000..3529b45d --- /dev/null +++ b/sites/webmd_doctor/app.py @@ -0,0 +1,1865 @@ +"""WebMD Doctor (doctor.webmd.com, branded "WebMD Care") local mirror for WebHarbor. + +Every doctor, practice, hospital, address, phone number, NPI and review is +synthetic. Only the site chrome (layout, colours, icons) mirrors upstream. +""" +from __future__ import annotations + +import math +import os +import re +import sys +from datetime import date, datetime, timedelta +from pathlib import Path +from urllib.parse import unquote, urlencode, urlsplit + +from flask import ( + Flask, + abort, + flash, + jsonify, + redirect, + render_template, + request, + 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 event +from sqlalchemy.engine import Engine +from sqlalchemy.exc import IntegrityError +from werkzeug.security import check_password_hash, generate_password_hash + +BASE_DIR = Path(os.path.dirname(os.path.abspath(__file__))) +INSTANCE_DIR = BASE_DIR / "instance" +DB_PATH = INSTANCE_DIR / "webmd_doctor.db" +INSTANCE_DIR.mkdir(parents=True, exist_ok=True) + +app = Flask(__name__, instance_path=str(INSTANCE_DIR)) +app.config.update( + SECRET_KEY=os.environ.get("WEBMD_DOCTOR_SECRET_KEY", "webharbor-webmd-doctor-dev-key"), + SQLALCHEMY_DATABASE_URI=f"sqlite:///{DB_PATH}", + SQLALCHEMY_TRACK_MODIFICATIONS=False, + WTF_CSRF_TIME_LIMIT=7200, + MAX_CONTENT_LENGTH=256 * 1024, + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + PERMANENT_SESSION_LIFETIME=timedelta(hours=12), +) + + +@event.listens_for(Engine, "connect") +def enable_sqlite_foreign_keys(connection, _record) -> None: + cursor = connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + +db = SQLAlchemy(app) +csrf = CSRFProtect(app) +login_manager = LoginManager(app) +login_manager.login_view = "login" +login_manager.login_message = None + +SEED_VERSION = "webmd-doctor-v1" +SITE_NAME = "WebMD Care" +PAGE_SIZE = 10 +REVIEW_PAGE_SIZE = 5 +DEFAULT_DISTANCE = 40 +DISTANCE_OPTIONS = (5, 10, 25, 50, 100) +EXPERIENCE_STOPS = ("min", "5", "15", "20", "25", "30", "max") +ANCHOR_LABEL = "Newark, DE 19711" +EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +MIN_PASSWORD_LENGTH = 8 +MIN_REVIEW_LENGTH = 20 +MAX_REVIEW_LENGTH = 2000 + +PERSPECTIVE_CRITERIA = ( + "Explained conditions and treatments", + "Answered my questions", + "Provided follow-up as needed", + "Gave a thorough Exam", + "Gave clear instructions", + "Staff was courteous", + "Flexible scheduling", +) +DOCTOR_POLL_QUESTIONS = ( + "Was {name}'s check in process easy?", + "Do you feel {name} listened to your medical concerns?", + "Did {name} follow-up with you after your initial visit?", + "Would you recommend {name} to other patients?", + "Does {name} offer telehealth / virtual visit options?", +) +HOSPITAL_POLL_QUESTIONS = ( + "Would you recommend this hospital to other patients?", + "Did the doctors at this hospital listen to your medical concerns?", + "Did doctors from this hospital follow-up with you after your initial visit?", + "Did the hospital give you clear instructions for recovering at home afterwards?", + "Were the facilities at this hospital kept clean?", +) +PRACTICE_POLL_QUESTIONS = ( + "Would you recommend this practice to other patients?", + "Did the doctors at this practice listen to your medical concerns?", + "Did doctors from this practice follow-up with you after your initial visit?", + "Did the practice give you clear instructions for recovering at home afterwards?", + "Were the facilities at this practice kept clean?", +) +# Fixed appointment grid (fidelity: upstream shows a rolling 4-day grid; the +# mirror is pinned to the reference date so every run sees the same slots). +BOOKING_DAYS = ( + ("THU", "SEP 10", date(2026, 9, 10), "Thu, Sep 10"), + ("FRI", "SEP 11", date(2026, 9, 11), "Fri, Sep 11"), + ("MON", "SEP 14", date(2026, 9, 14), "Mon, Sep 14"), + ("TUE", "SEP 15", date(2026, 9, 15), "Tue, Sep 15"), +) +BOOKING_SLOTS = ("9:00 AM", "9:30 AM", "10:00 AM", "10:30 AM", "11:00 AM") +PATIENT_TYPES = ("New Patient", "Returning Patient") +AWARD_CLASSES = { + "elite": ("Elite", "WebMD Elite Choice award", "WebMD Elite Choice"), + "patient": ("Patient", "WebMD Patient's Choice award", "WebMD Patient's Choice"), + "provider": ("Provider", "Medscape Provider Choice award", "Medscape Provider Choice"), +} +AWARD_LINE_BY_CLASS = {value[0]: value[1] for value in AWARD_CLASSES.values()} +BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" +DAY_KEYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun") +DAY_LABELS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") +SORT_OPTIONS = ( + ("bestmatch", "Best Match"), + ("distance", "Distance"), + ("avg_rating", "Average Rating"), + ("num_rating", "Number of Ratings"), +) +HUB_SORT_OPTIONS = ( + ("bestmatch", "Best Match"), + ("avg_rating", "Average Rating"), + ("num_rating", "Number of Ratings"), +) + + +# --------------------------------------------------------------------------- # +# Models +# --------------------------------------------------------------------------- # +class PollMixin: + poll_q1_yes = db.Column(db.Integer, nullable=False, default=0) + poll_q1_no = db.Column(db.Integer, nullable=False, default=0) + poll_q2_yes = db.Column(db.Integer, nullable=False, default=0) + poll_q2_no = db.Column(db.Integer, nullable=False, default=0) + poll_q3_yes = db.Column(db.Integer, nullable=False, default=0) + poll_q3_no = db.Column(db.Integer, nullable=False, default=0) + poll_q4_yes = db.Column(db.Integer, nullable=False, default=0) + poll_q4_no = db.Column(db.Integer, nullable=False, default=0) + poll_q5_yes = db.Column(db.Integer, nullable=False, default=0) + poll_q5_no = db.Column(db.Integer, nullable=False, default=0) + + def poll_rows(self, questions, name: str = ""): + rows = [] + for index, question in enumerate(questions, start=1): + rows.append( + { + "question": question.format(name=name), + "yes": getattr(self, f"poll_q{index}_yes"), + "no": getattr(self, f"poll_q{index}_no"), + } + ) + return rows + + +class HoursMixin: + mon_open = db.Column(db.String(16)) + mon_close = db.Column(db.String(16)) + tue_open = db.Column(db.String(16)) + tue_close = db.Column(db.String(16)) + wed_open = db.Column(db.String(16)) + wed_close = db.Column(db.String(16)) + thu_open = db.Column(db.String(16)) + thu_close = db.Column(db.String(16)) + fri_open = db.Column(db.String(16)) + fri_close = db.Column(db.String(16)) + sat_open = db.Column(db.String(16)) + sat_close = db.Column(db.String(16)) + sun_open = db.Column(db.String(16)) + sun_close = db.Column(db.String(16)) + + def hours_rows(self): + rows = [] + for key, label in zip(DAY_KEYS, DAY_LABELS): + opens = getattr(self, f"{key}_open") + closes = getattr(self, f"{key}_close") + rows.append({"day": label, "text": f"{opens} - {closes}" if opens and closes else "Closed"}) + return rows + + +class Specialty(db.Model): + __tablename__ = "specialties" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), nullable=False, unique=True) + slug = db.Column(db.String(80), nullable=False, unique=True) + singular = db.Column(db.String(80), nullable=False) + plural = db.Column(db.String(80), nullable=False) + description = db.Column(db.Text, nullable=False) + board_name = db.Column(db.String(120), nullable=False) + display_order = db.Column(db.Integer, nullable=False, default=0) + + +class Condition(db.Model): + __tablename__ = "conditions" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False, unique=True) + slug = db.Column(db.String(120), nullable=False, unique=True) + specialty_id = db.Column(db.Integer, db.ForeignKey("specialties.id"), nullable=False) + specialty = db.relationship("Specialty") + + +class Procedure(db.Model): + __tablename__ = "procedures" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False, unique=True) + slug = db.Column(db.String(120), nullable=False, unique=True) + specialty_id = db.Column(db.Integer, db.ForeignKey("specialties.id"), nullable=False) + specialty = db.relationship("Specialty") + + +class ExpertiseArea(db.Model): + __tablename__ = "expertise_areas" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False, unique=True) + specialty_id = db.Column(db.Integer, db.ForeignKey("specialties.id"), nullable=False) + + +class Insurer(db.Model): + __tablename__ = "insurers" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), nullable=False, unique=True) + slug = db.Column(db.String(80), nullable=False, unique=True) + plans = db.relationship("InsurancePlan", back_populates="insurer", order_by="InsurancePlan.id") + + +class InsurancePlan(db.Model): + __tablename__ = "insurance_plans" + id = db.Column(db.Integer, primary_key=True) + insurer_id = db.Column(db.Integer, db.ForeignKey("insurers.id"), nullable=False) + plan_type = db.Column(db.String(40), nullable=False, default="") + insurer = db.relationship("Insurer", back_populates="plans") + + @property + def label(self) -> str: + return f"{self.insurer.name} {self.plan_type}".strip() + + +class City(db.Model): + __tablename__ = "cities" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), nullable=False) + slug = db.Column(db.String(80), nullable=False) + state = db.Column(db.String(2), nullable=False) + state_name = db.Column(db.String(40), nullable=False) + state_slug = db.Column(db.String(40), nullable=False) + lat = db.Column(db.Float, nullable=False) + lon = db.Column(db.Float, nullable=False) + zips = db.relationship("CityZip", back_populates="city", order_by="CityZip.zip") + + @property + def label(self) -> str: + return f"{self.name}, {self.state}" + + +class CityZip(db.Model): + __tablename__ = "city_zips" + id = db.Column(db.Integer, primary_key=True) + city_id = db.Column(db.Integer, db.ForeignKey("cities.id"), nullable=False) + zip = db.Column(db.String(5), nullable=False, unique=True) + city = db.relationship("City", back_populates="zips") + + +class Hospital(PollMixin, db.Model): + __tablename__ = "hospitals" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False, unique=True) + slug = db.Column(db.String(140), nullable=False, unique=True) + city_id = db.Column(db.Integer, db.ForeignKey("cities.id"), nullable=False) + street = db.Column(db.String(120), nullable=False) + zip = db.Column(db.String(5), nullable=False) + phone = db.Column(db.String(20), nullable=False) + website = db.Column(db.String(120), nullable=False) + overview_text = db.Column(db.Text, nullable=False) + avg_rating = db.Column(db.Float) + ratings_count = db.Column(db.Integer, nullable=False, default=0) + city = db.relationship("City") + doctors = db.relationship("Doctor", back_populates="hospital", order_by="Doctor.last_name") + + +class Practice(PollMixin, HoursMixin, db.Model): + __tablename__ = "practices" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False, unique=True) + slug = db.Column(db.String(140), nullable=False, unique=True) + city_id = db.Column(db.Integer, db.ForeignKey("cities.id"), nullable=False) + street = db.Column(db.String(120), nullable=False) + zip = db.Column(db.String(5), nullable=False) + phone = db.Column(db.String(20), nullable=False) + website = db.Column(db.String(120), nullable=False) + overview_text = db.Column(db.Text, nullable=False) + avg_rating = db.Column(db.Float) + ratings_count = db.Column(db.Integer, nullable=False, default=0) + city = db.relationship("City") + locations = db.relationship("Location", back_populates="practice", order_by="Location.id") + + +class Doctor(PollMixin, db.Model): + __tablename__ = "doctors" + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(120), nullable=False, unique=True) + prefix = db.Column(db.String(8), nullable=False, default="Dr.") + first_name = db.Column(db.String(60), nullable=False) + last_name = db.Column(db.String(60), nullable=False) + degree = db.Column(db.String(8), nullable=False) + gender = db.Column(db.String(1), nullable=False) + profile_type = db.Column(db.String(10), nullable=False) + primary_specialty_id = db.Column(db.Integer, db.ForeignKey("specialties.id"), nullable=False) + secondary_specialty_id = db.Column(db.Integer, db.ForeignKey("specialties.id")) + hospital_id = db.Column(db.Integer, db.ForeignKey("hospitals.id")) + avg_rating = db.Column(db.Float) + ratings_count = db.Column(db.Integer, nullable=False, default=0) + text_review_count = db.Column(db.Integer, nullable=False, default=0) + years_experience = db.Column(db.Integer, nullable=False) + graduation_year = db.Column(db.Integer, nullable=False) + medical_school = db.Column(db.String(120), nullable=False) + accepting_new_patients = db.Column(db.Boolean, nullable=False, default=True) + virtual_visit = db.Column(db.Boolean, nullable=False, default=False) + npi = db.Column(db.String(10), nullable=False, unique=True) + overview_text = db.Column(db.Text, nullable=False) + bio_html = db.Column(db.Text) + avg_wait_minutes = db.Column(db.Integer) + callout_label = db.Column(db.String(80)) + video_poster_file = db.Column(db.String(160)) + next_available_label = db.Column(db.String(40)) + website_url = db.Column(db.String(160)) + + primary_specialty = db.relationship("Specialty", foreign_keys=[primary_specialty_id]) + secondary_specialty = db.relationship("Specialty", foreign_keys=[secondary_specialty_id]) + hospital = db.relationship("Hospital", back_populates="doctors") + locations = db.relationship( + "Location", back_populates="doctor", order_by="desc(Location.is_primary), Location.id" + ) + conditions = db.relationship("DoctorCondition", order_by="DoctorCondition.position") + procedures = db.relationship("DoctorProcedure", order_by="DoctorProcedure.position") + expertise = db.relationship("DoctorExpertise", order_by="DoctorExpertise.position") + insurances = db.relationship("DoctorInsurance", order_by="DoctorInsurance.plan_id") + reviews = db.relationship("Review", order_by="desc(Review.review_date), Review.id") + perspectives = db.relationship("DoctorPerspective", order_by="DoctorPerspective.criterion") + certifications = db.relationship("Certification", order_by="Certification.id") + licenses = db.relationship("License", order_by="License.id") + education = db.relationship("Education", order_by="Education.id") + awards = db.relationship("Award", order_by="Award.id") + languages = db.relationship("DoctorLanguage", order_by="DoctorLanguage.position") + + @property + def is_enhanced(self) -> bool: + return self.profile_type == "Enhanced" + + @property + def display_name(self) -> str: + prefix = f"{self.prefix} " if self.prefix else "" + return f"{prefix}{self.first_name} {self.last_name}, {self.degree}" + + @property + def short_name(self) -> str: + prefix = f"{self.prefix} " if self.prefix else "" + return f"{prefix}{self.last_name}" + + @property + def full_name(self) -> str: + prefix = f"{self.prefix} " if self.prefix else "" + return f"{prefix}{self.first_name} {self.last_name}" + + @property + def pronoun(self) -> str: + return {"m": "he", "f": "she"}.get(self.gender, "they") + + @property + def possessive(self) -> str: + return {"m": "his", "f": "her"}.get(self.gender, "their") + + @property + def primary_location(self): + for location in self.locations: + if location.is_primary: + return location + return self.locations[0] if self.locations else None + + @property + def other_location_count(self) -> int: + return max(len(self.locations) - 1, 0) + + @property + def avatar_path(self) -> str: + return f"images/avatars/{self.slug}.png" + + @property + def featured_review(self): + for review in self.reviews: + if review.is_featured: + return review + return None + + @property + def card_snippet(self) -> str: + review = self.featured_review + text = review.text if review is not None else self.overview_text + return snippet(text, 150) + + @property + def award_lines(self) -> list[str]: + return [AWARD_LINE_BY_CLASS[award.award_class] for award in self.awards] + + @property + def language_names(self) -> list[str]: + return [row.language for row in self.languages] + + @property + def specialty_names(self) -> list[str]: + names = [self.primary_specialty.name] + if self.secondary_specialty is not None: + names.append(self.secondary_specialty.name) + return names + + def education_rows(self, kind: str): + return [row for row in self.education if row.kind == kind] + + def insurer_names(self) -> list[str]: + seen: list[str] = [] + for row in self.insurances: + name = row.plan.insurer.name + if name not in seen: + seen.append(name) + return seen + + def perspective_summary(self): + ordered = sorted(self.perspectives, key=lambda row: (-row.did_well, row.criterion)) + return [PERSPECTIVE_CRITERIA[row.criterion - 1] for row in ordered[:4]] + + +class Location(HoursMixin, db.Model): + __tablename__ = "locations" + id = db.Column(db.Integer, primary_key=True) + doctor_id = db.Column(db.Integer, db.ForeignKey("doctors.id"), nullable=False) + practice_id = db.Column(db.Integer, db.ForeignKey("practices.id"), nullable=False) + name = db.Column(db.String(140), nullable=False) + street = db.Column(db.String(120), nullable=False) + city_id = db.Column(db.Integer, db.ForeignKey("cities.id"), nullable=False) + zip = db.Column(db.String(5), nullable=False) + lat = db.Column(db.Float, nullable=False) + lon = db.Column(db.Float, nullable=False) + phone = db.Column(db.String(20), nullable=False) + is_primary = db.Column(db.Boolean, nullable=False, default=False) + medicare = db.Column(db.Boolean, nullable=False, default=False) + medicaid = db.Column(db.Boolean, nullable=False, default=False) + new_patients = db.Column(db.Boolean, nullable=False, default=True) + doctor = db.relationship("Doctor", back_populates="locations") + practice = db.relationship("Practice", back_populates="locations") + city = db.relationship("City") + + @property + def address_line(self) -> str: + return f"{self.street}, {self.city.name}, {self.city.state}, {self.zip}" + + @property + def short_address(self) -> str: + return f"{self.street}, {self.city.name}, {self.city.state} {self.zip}" + + +class DoctorCondition(db.Model): + __tablename__ = "doctor_conditions" + id = db.Column(db.Integer, primary_key=True) + doctor_id = db.Column(db.Integer, db.ForeignKey("doctors.id"), nullable=False) + condition_id = db.Column(db.Integer, db.ForeignKey("conditions.id"), nullable=False) + tier = db.Column(db.String(16), nullable=False) + position = db.Column(db.Integer, nullable=False) + condition = db.relationship("Condition") + __table_args__ = (db.UniqueConstraint("doctor_id", "condition_id"),) + + +class DoctorProcedure(db.Model): + __tablename__ = "doctor_procedures" + id = db.Column(db.Integer, primary_key=True) + doctor_id = db.Column(db.Integer, db.ForeignKey("doctors.id"), nullable=False) + procedure_id = db.Column(db.Integer, db.ForeignKey("procedures.id"), nullable=False) + tier = db.Column(db.String(16), nullable=False) + position = db.Column(db.Integer, nullable=False) + procedure = db.relationship("Procedure") + __table_args__ = (db.UniqueConstraint("doctor_id", "procedure_id"),) + + +class DoctorExpertise(db.Model): + __tablename__ = "doctor_expertise" + id = db.Column(db.Integer, primary_key=True) + doctor_id = db.Column(db.Integer, db.ForeignKey("doctors.id"), nullable=False) + area_id = db.Column(db.Integer, db.ForeignKey("expertise_areas.id"), nullable=False) + position = db.Column(db.Integer, nullable=False) + area = db.relationship("ExpertiseArea") + __table_args__ = (db.UniqueConstraint("doctor_id", "area_id"),) + + +class DoctorInsurance(db.Model): + __tablename__ = "doctor_insurances" + id = db.Column(db.Integer, primary_key=True) + doctor_id = db.Column(db.Integer, db.ForeignKey("doctors.id"), nullable=False) + plan_id = db.Column(db.Integer, db.ForeignKey("insurance_plans.id"), nullable=False) + is_verified = db.Column(db.Boolean, nullable=False, default=True) + plan = db.relationship("InsurancePlan") + __table_args__ = (db.UniqueConstraint("doctor_id", "plan_id"),) + + +class Review(db.Model): + __tablename__ = "reviews" + id = db.Column(db.Integer, primary_key=True) + doctor_id = db.Column(db.Integer, db.ForeignKey("doctors.id"), nullable=False) + rating = db.Column(db.Integer, nullable=False) + text = db.Column(db.Text, nullable=False) + review_date = db.Column(db.Date, nullable=False) + helpful_count = db.Column(db.Integer, nullable=False, default=0) + c1 = db.Column(db.Integer, nullable=False, default=1) + c2 = db.Column(db.Integer, nullable=False, default=1) + c3 = db.Column(db.Integer, nullable=False, default=1) + c4 = db.Column(db.Integer, nullable=False, default=1) + c5 = db.Column(db.Integer, nullable=False, default=1) + c6 = db.Column(db.Integer, nullable=False, default=1) + c7 = db.Column(db.Integer, nullable=False, default=1) + wait_bucket = db.Column(db.String(24), nullable=False, default="") + is_featured = db.Column(db.Boolean, nullable=False, default=False) + + @property + def date_label(self) -> str: + return f"{self.review_date.strftime('%B')} {self.review_date.day}, {self.review_date.year}" + + def criteria_rows(self): + return [ + (label, getattr(self, f"c{index}") == 1) + for index, label in enumerate(PERSPECTIVE_CRITERIA, start=1) + ] + + +class DoctorPerspective(db.Model): + __tablename__ = "doctor_perspectives" + id = db.Column(db.Integer, primary_key=True) + doctor_id = db.Column(db.Integer, db.ForeignKey("doctors.id"), nullable=False) + criterion = db.Column(db.Integer, nullable=False) + did_well = db.Column(db.Integer, nullable=False, default=0) + needs_improvement = db.Column(db.Integer, nullable=False, default=0) + __table_args__ = (db.UniqueConstraint("doctor_id", "criterion"),) + + @property + def label(self) -> str: + return PERSPECTIVE_CRITERIA[self.criterion - 1] + + @property + def did_well_percent(self) -> int: + total = self.did_well + self.needs_improvement + return int(round(100 * self.did_well / total)) if total else 0 + + +class Certification(db.Model): + __tablename__ = "certifications" + id = db.Column(db.Integer, primary_key=True) + doctor_id = db.Column(db.Integer, db.ForeignKey("doctors.id"), nullable=False) + issuer = db.Column(db.String(120), nullable=False) + cert_type = db.Column(db.String(120), nullable=False) + year = db.Column(db.Integer, nullable=False) + + +class License(db.Model): + __tablename__ = "licenses" + id = db.Column(db.Integer, primary_key=True) + doctor_id = db.Column(db.Integer, db.ForeignKey("doctors.id"), nullable=False) + license_type = db.Column(db.String(80), nullable=False) + state = db.Column(db.String(40), nullable=False) + expiry_date = db.Column(db.Date, nullable=False) + status = db.Column(db.String(20), nullable=False, default="Active") + + +class Education(db.Model): + __tablename__ = "education" + id = db.Column(db.Integer, primary_key=True) + doctor_id = db.Column(db.Integer, db.ForeignKey("doctors.id"), nullable=False) + kind = db.Column(db.String(24), nullable=False) + institution = db.Column(db.String(140), nullable=False) + year = db.Column(db.Integer, nullable=False) + + +class Award(db.Model): + __tablename__ = "awards" + id = db.Column(db.Integer, primary_key=True) + doctor_id = db.Column(db.Integer, db.ForeignKey("doctors.id"), nullable=False) + award_class = db.Column(db.String(16), nullable=False) + year = db.Column(db.Integer, nullable=False) + doctor = db.relationship("Doctor", overlaps="awards") + + +class DoctorLanguage(db.Model): + __tablename__ = "doctor_languages" + id = db.Column(db.Integer, primary_key=True) + doctor_id = db.Column(db.Integer, db.ForeignKey("doctors.id"), nullable=False) + language = db.Column(db.String(40), nullable=False) + position = db.Column(db.Integer, nullable=False) + + +class User(UserMixin, db.Model): + __tablename__ = "users" + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(160), nullable=False, unique=True) + password_hash = db.Column(db.String(256), nullable=False) + dob = db.Column(db.Date) + display_name = db.Column(db.String(80), nullable=False) + created_at = db.Column(db.DateTime, nullable=False) + + +class SavedProvider(db.Model): + __tablename__ = "saved_providers" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + doctor_id = db.Column(db.Integer, db.ForeignKey("doctors.id"), nullable=False) + saved_at = db.Column(db.DateTime, nullable=False) + doctor = db.relationship("Doctor") + __table_args__ = (db.UniqueConstraint("user_id", "doctor_id"),) + + +class AppointmentRequest(db.Model): + __tablename__ = "appointment_requests" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + doctor_id = db.Column(db.Integer, db.ForeignKey("doctors.id"), nullable=False) + location_id = db.Column(db.Integer, db.ForeignKey("locations.id"), nullable=False) + patient_type = db.Column(db.String(24), nullable=False) + slot_date = db.Column(db.Date, nullable=False) + slot_time = db.Column(db.String(12), nullable=False) + reference = db.Column(db.String(12), nullable=False, unique=True) + created_at = db.Column(db.DateTime, nullable=False) + doctor = db.relationship("Doctor") + location = db.relationship("Location") + + @property + def slot_label(self) -> str: + for _abbr, _short, day, label in BOOKING_DAYS: + if day == self.slot_date: + return f"{label} @ {self.slot_time}" + return f"{self.slot_date.strftime('%a, %b')} {self.slot_date.day} @ {self.slot_time}" + + +class UserReview(db.Model): + __tablename__ = "user_reviews" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + doctor_id = db.Column(db.Integer, db.ForeignKey("doctors.id"), nullable=False) + rating = db.Column(db.Integer, nullable=False) + c1 = db.Column(db.Integer, nullable=False, default=1) + c2 = db.Column(db.Integer, nullable=False, default=1) + c3 = db.Column(db.Integer, nullable=False, default=1) + c4 = db.Column(db.Integer, nullable=False, default=1) + c5 = db.Column(db.Integer, nullable=False, default=1) + c6 = db.Column(db.Integer, nullable=False, default=1) + c7 = db.Column(db.Integer, nullable=False, default=1) + text = db.Column(db.Text, nullable=False) + status = db.Column(db.String(24), nullable=False, default="Pending review") + created_at = db.Column(db.DateTime, nullable=False) + + +class SeedMetadata(db.Model): + __tablename__ = "seed_metadata" + key = db.Column(db.String(40), primary_key=True) + value = db.Column(db.String(80), nullable=False) + + +@login_manager.user_loader +def load_user(user_id: str): + return db.session.get(User, int(user_id)) + + +# --------------------------------------------------------------------------- # +# Helpers +# --------------------------------------------------------------------------- # +def snippet(text: str, limit: int) -> str: + text = (text or "").strip() + if len(text) <= limit: + return text + cut = text[:limit].rsplit(" ", 1)[0] + return cut.rstrip(",;:") + " ..." + + +def haversine_miles(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + radius = 3958.7613 + p1, p2 = math.radians(lat1), math.radians(lat2) + dp = math.radians(lat2 - lat1) + dl = math.radians(lon2 - lon1) + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 + return 2 * radius * math.asin(math.sqrt(a)) + + +def confirmation_reference(row_id: int) -> str: + """Fixed affine permutation of the row id rendered as six base-32 characters.""" + value = (row_id * 0x5A7B3 + 0x2C9F1) % (32**6) + chars = [] + for _ in range(6): + chars.append(BASE32_ALPHABET[value % 32]) + value //= 32 + return "WMD-" + "".join(reversed(chars)) + + +def safe_next(raw: str | None) -> str | None: + """Return a same-origin relative path (with query) or ``None``.""" + if not raw or len(raw) > 2048 or any(ord(char) < 32 for char in raw): + return None + decoded = raw + for _ in range(3): + expanded = unquote(decoded) + if expanded == decoded: + break + decoded = expanded + if decoded.startswith("//") or "\\" in decoded: + return None + parsed = urlsplit(decoded) + if parsed.scheme or parsed.netloc or not parsed.path.startswith("/") or parsed.path.startswith("//"): + return None + return parsed.path + (("?" + parsed.query) if parsed.query else "") + + +def single_arg(name: str, default: str = "") -> str: + values = request.args.getlist(name) + return values[0].strip() if values else default + + +def int_arg(name: str, default: int, minimum: int = 1, maximum: int = 10**6) -> int: + raw = single_arg(name, "") + if not raw.isdigit(): + return default + return min(max(int(raw), minimum), maximum) + + +def bool_arg(name: str) -> bool: + return single_arg(name, "").lower() in ("true", "1", "yes", "on") + + +def paginate(items: list, page: int, size: int = PAGE_SIZE): + total = len(items) + pages = max(1, math.ceil(total / size)) + page = min(max(page, 1), pages) + start = (page - 1) * size + return { + "rows": items[start:start + size], + "page": page, + "pages": pages, + "total": total, + "start": start + 1 if total else 0, + "end": min(start + size, total), + "numbers": page_numbers(page, pages), + } + + +def page_numbers(page: int, pages: int) -> list: + if pages <= 7: + return list(range(1, pages + 1)) + numbers = {1, 2, pages - 1, pages, page - 1, page, page + 1} + ordered = sorted(n for n in numbers if 1 <= n <= pages) + result = [] + previous = 0 + for number in ordered: + if number - previous > 1: + result.append(None) + result.append(number) + previous = number + return result + + +def anchor_city() -> City: + return City.query.filter_by(slug="newark", state="DE").one() + + +def location_choices() -> list[dict]: + choices = [] + for city in City.query.order_by(City.state, City.name).all(): + for row in city.zips: + choices.append({"label": f"{city.name}, {city.state} {row.zip}", "zip": row.zip}) + return choices + + +def resolve_location(loc: str, zc: str = "", city_name: str = "", state: str = "") -> dict: + """Resolve the location input to a seeded city; unknown input falls back to the anchor.""" + anchor = anchor_city() + raw = (loc or "").strip() + zip_code = zc.strip() + if not zip_code: + match = re.search(r"\b(\d{5})\b", raw) + if match: + zip_code = match.group(1) + if zip_code: + row = CityZip.query.filter_by(zip=zip_code).first() + if row is not None: + return {"city": row.city, "zip": row.zip, "label": f"{row.city.name}, {row.city.state} {row.zip}", "fallback": False, "input": raw} + name = city_name.strip() + state_code = state.strip().upper() + if not name and raw: + parts = [part.strip() for part in re.split(r"[,]", raw) if part.strip()] + if parts: + name = parts[0] + if len(parts) > 1: + state_code = parts[1].split()[0].upper() if parts[1].split() else "" + if name: + query = City.query.filter(db.func.lower(City.name) == name.lower()) + if state_code: + query = query.filter(City.state == state_code) + city = query.order_by(City.id).first() + if city is not None: + first_zip = city.zips[0].zip if city.zips else "" + return {"city": city, "zip": first_zip, "label": f"{city.name}, {city.state} {first_zip}".strip(), "fallback": False, "input": raw} + if not raw and not zip_code and not name: + return {"city": anchor, "zip": "19711", "label": ANCHOR_LABEL, "fallback": False, "input": raw} + return {"city": anchor, "zip": "19711", "label": ANCHOR_LABEL, "fallback": True, "input": raw or zip_code or name} + + +def search_vocabulary() -> list[tuple[str, str, object]]: + """(lower-cased term, kind, value) sorted longest first for the query parser.""" + entries: list[tuple[str, str, object]] = [] + for specialty in Specialty.query.order_by(Specialty.id).all(): + for term in (specialty.name, specialty.singular, specialty.plural): + entries.append((term.lower(), "sids", specialty.id)) + for condition in Condition.query.order_by(Condition.id).all(): + entries.append((condition.name.lower(), "cid", condition.id)) + for procedure in Procedure.query.order_by(Procedure.id).all(): + entries.append((procedure.name.lower(), "pid", procedure.id)) + for insurer in Insurer.query.order_by(Insurer.id).all(): + entries.append((insurer.name.lower(), "insuranceid", insurer.id)) + for term, value in (("female", "f"), ("male", "m"), ("non-binary", "n"), ("nonbinary", "n")): + entries.append((term, "gender", value)) + for term in ("virtual", "telehealth", "video visit"): + entries.append((term, "isvirtualvisit", True)) + entries.sort(key=lambda entry: (-len(entry[0]), entry[0])) + return entries + + +def resolve_query(q: str) -> dict: + """Deterministic longest-match-first parse of the search term (no fuzziness).""" + resolved: dict = {"sids": None, "cid": None, "pid": None, "insuranceid": None, "gender": None, "isvirtualvisit": False, "matched": []} + text = " " + re.sub(r"\s+", " ", (q or "").lower()).strip() + " " + for term, kind, value in search_vocabulary(): + pattern = r"(? tuple[int | None, int | None]: + if not raw or "_" not in raw: + return None, None + low, high = raw.split("_", 1) + if low not in EXPERIENCE_STOPS or high not in EXPERIENCE_STOPS: + return None, None + low_value = None if low == "min" else int(low) + high_value = None if high == "max" else int(high) + if low_value is not None and high_value is not None and low_value > high_value: + return None, None + return low_value, high_value + + +def read_search_params(fixed: dict | None = None) -> dict: + """Collect the filter parameters for a results-style page.""" + fixed = fixed or {} + q = single_arg("q", "")[:160] + resolved = resolve_query(q) + sids_raw = single_arg("sids", "") + sids = int(sids_raw) if sids_raw.isdigit() else resolved["sids"] + cid_raw = single_arg("cid", "") + pid_raw = single_arg("pid", "") + insurer_raw = single_arg("insuranceid", "") + gender = single_arg("gender", "").lower() + if gender not in ("m", "f", "n"): + gender = resolved["gender"] or "all" + sort = single_arg("sortby", "bestmatch") + if sort not in {key for key, _label in SORT_OPTIONS}: + sort = "bestmatch" + distance = int_arg("d", DEFAULT_DISTANCE, 1, 100) + if single_arg("d", "") and distance not in DISTANCE_OPTIONS: + distance = DEFAULT_DISTANCE + minrating = int_arg("minrating", 0, 1, 5) if single_arg("minrating", "").isdigit() else 0 + exp_raw = single_arg("exp", "") + exp_min, exp_max = parse_experience(exp_raw) + params = { + "q": q, + "resolved": resolved, + "sids": fixed.get("sids", sids), + "cid": int(cid_raw) if cid_raw.isdigit() else resolved["cid"], + "pid": int(pid_raw) if pid_raw.isdigit() else resolved["pid"], + "insuranceid": int(insurer_raw) if insurer_raw.isdigit() else resolved["insuranceid"], + "gender": gender, + "isvirtualvisit": bool_arg("isvirtualvisit") or resolved["isvirtualvisit"], + "newpatient": bool_arg("newpatient"), + "medicare": bool_arg("medicare"), + "medicaid": bool_arg("medicaid"), + "minrating": minrating, + "d": distance, + "exp": exp_raw if (exp_min is not None or exp_max is not None) else "", + "exp_min": exp_min, + "exp_max": exp_max, + "sortby": sort, + "page": int_arg("page", 1, 1, 10**4), + "city_id": fixed.get("city_id"), + "state": fixed.get("state"), + "use_distance": fixed.get("use_distance", True), + } + return params + + +def match_score(doctor: Doctor, params: dict, condition_ids: set, procedure_ids: set, insurer_ids: set) -> int: + score = 0 + if params["sids"] is not None: + if doctor.primary_specialty_id == params["sids"]: + score += 3 + elif doctor.secondary_specialty_id == params["sids"]: + score += 2 + if params["cid"] is not None and params["cid"] in condition_ids: + score += 1 + if params["pid"] is not None and params["pid"] in procedure_ids: + score += 1 + if params["insuranceid"] is not None and params["insuranceid"] in insurer_ids: + score += 1 + return score + + +def rank(rows: list[dict], sortby: str) -> list[dict]: + """Pure ordering of matched rows. Nulls (unrated) sort last under every sort.""" + def rating_key(row): + rating = row["doctor"].avg_rating + return (0, -rating) if rating is not None else (1, 0.0) + + if sortby == "distance": + key = lambda row: (row["distance"], row["doctor"].id) + elif sortby == "avg_rating": + key = lambda row: (rating_key(row), row["doctor"].id) + elif sortby == "num_rating": + key = lambda row: (-row["doctor"].ratings_count, row["doctor"].id) + else: + key = lambda row: (-row["score"], rating_key(row), -row["doctor"].ratings_count, row["doctor"].id) + return sorted(rows, key=key) + + +def search_doctors(params: dict, center: City) -> list[dict]: + """Apply the conjunctive filters, compute distance + match score, return ranked rows.""" + query = Doctor.query + if params["sids"] is not None: + query = query.filter(db.or_(Doctor.primary_specialty_id == params["sids"], Doctor.secondary_specialty_id == params["sids"])) + if params["gender"] in ("m", "f", "n"): + query = query.filter(Doctor.gender == params["gender"]) + if params["isvirtualvisit"]: + query = query.filter(Doctor.virtual_visit.is_(True)) + if params["newpatient"]: + query = query.filter(Doctor.accepting_new_patients.is_(True)) + if params["minrating"]: + query = query.filter(Doctor.avg_rating.isnot(None), Doctor.avg_rating >= params["minrating"]) + if params["exp_min"] is not None: + query = query.filter(Doctor.years_experience >= params["exp_min"]) + if params["exp_max"] is not None: + query = query.filter(Doctor.years_experience <= params["exp_max"]) + if params["cid"] is not None: + query = query.filter(Doctor.conditions.any(DoctorCondition.condition_id == params["cid"])) + if params["pid"] is not None: + query = query.filter(Doctor.procedures.any(DoctorProcedure.procedure_id == params["pid"])) + if params["insuranceid"] is not None: + query = query.filter(Doctor.insurances.any(DoctorInsurance.plan.has(InsurancePlan.insurer_id == params["insuranceid"]))) + primary = Location.is_primary.is_(True) + location_filters = [primary] + if params["medicare"]: + location_filters.append(Location.medicare.is_(True)) + if params["medicaid"]: + location_filters.append(Location.medicaid.is_(True)) + if params["city_id"] is not None: + location_filters.append(Location.city_id == params["city_id"]) + if params["state"]: + location_filters.append(Location.city.has(City.state == params["state"])) + query = query.filter(Doctor.locations.any(db.and_(*location_filters))) + rows = [] + for doctor in query.order_by(Doctor.id).all(): + location = doctor.primary_location + distance = haversine_miles(center.lat, center.lon, location.lat, location.lon) + if params["use_distance"] and distance > params["d"]: + continue + condition_ids = {row.condition_id for row in doctor.conditions} if params["cid"] is not None else set() + procedure_ids = {row.procedure_id for row in doctor.procedures} if params["pid"] is not None else set() + insurer_ids = {row.plan.insurer_id for row in doctor.insurances} if params["insuranceid"] is not None else set() + rows.append({ + "doctor": doctor, + "distance": distance, + "score": match_score(doctor, params, condition_ids, procedure_ids, insurer_ids), + }) + return rank(rows, params["sortby"]) + + +def filter_query_string(params: dict, **overrides) -> str: + """Rebuild the query string for filter/sort/pagination links.""" + values = { + "q": params["q"], + "loc": params.get("loc_label", ""), + "sortby": params["sortby"] if params["sortby"] != "bestmatch" else "", + "minrating": params["minrating"] or "", + "newpatient": "true" if params["newpatient"] else "", + "insuranceid": params["insuranceid"] or "", + "medicare": "true" if params["medicare"] else "", + "medicaid": "true" if params["medicaid"] else "", + "d": params["d"] if params["use_distance"] and params["d"] != DEFAULT_DISTANCE else "", + "exp": params["exp"], + "gender": params["gender"] if params["gender"] != "all" else "", + "isvirtualvisit": "true" if params["isvirtualvisit"] else "", + "cid": params["cid"] or "", + "pid": params["pid"] or "", + "page": "", + } + if params.get("sids_explicit"): + values["sids"] = params["sids"] + values.update(overrides) + clean = {key: value for key, value in values.items() if value not in ("", None, False)} + return urlencode(clean) + + +def search_heading_term(params: dict) -> str: + if params["sids"] is not None: + specialty = db.session.get(Specialty, params["sids"]) + if specialty is not None: + return specialty.singular + if params["cid"] is not None: + condition = db.session.get(Condition, params["cid"]) + if condition is not None: + return condition.name + if params["pid"] is not None: + procedure = db.session.get(Procedure, params["pid"]) + if procedure is not None: + return procedure.name + if params["insuranceid"] is not None: + insurer = db.session.get(Insurer, params["insuranceid"]) + if insurer is not None: + return f"Providers accepting {insurer.name}" + return params["q"].strip() or "All Providers" + + +def filter_bar_context(params: dict, *, show_distance: bool = True) -> dict: + return { + "params": params, + "insurers": Insurer.query.order_by(Insurer.name).all(), + "sort_options": SORT_OPTIONS, + "distance_options": DISTANCE_OPTIONS, + "experience_stops": EXPERIENCE_STOPS, + "show_distance": show_distance, + "qs": lambda **overrides: filter_query_string(params, **overrides), + } + + +def saved_doctor_ids() -> set[int]: + if not current_user.is_authenticated: + return set() + return {row.doctor_id for row in SavedProvider.query.filter_by(user_id=current_user.id).all()} + + +def hub_sorted(items: list, sortby: str, minrating: int) -> list: + if minrating: + items = [item for item in items if item.avg_rating is not None and item.avg_rating >= minrating] + + def rating_key(item): + return (0, -item.avg_rating) if item.avg_rating is not None else (1, 0.0) + + if sortby == "avg_rating": + return sorted(items, key=lambda item: (rating_key(item), item.id)) + if sortby == "num_rating": + return sorted(items, key=lambda item: (-item.ratings_count, item.id)) + return sorted(items, key=lambda item: (item.name.lower(), item.id)) + + +def states_with_counts(rows: list) -> list[dict]: + """rows: (state, state_name, state_slug) tuples with duplicates -> ordered unique with counts.""" + counts: dict = {} + for state, state_name, state_slug in rows: + entry = counts.setdefault(state, {"state": state, "name": state_name, "slug": state_slug, "count": 0}) + entry["count"] += 1 + return sorted(counts.values(), key=lambda entry: entry["name"]) + + +def specialties_for_menu(): + return Specialty.query.order_by(Specialty.display_order, Specialty.name).all() + + +@app.context_processor +def inject_globals(): + return { + "site_name": SITE_NAME, + "menu_specialties": specialties_for_menu(), + "anchor_label": ANCHOR_LABEL, + "location_choices": location_choices(), + "sort_options": SORT_OPTIONS, + "hub_sort_options": HUB_SORT_OPTIONS, + "perspective_criteria": PERSPECTIVE_CRITERIA, + "award_classes": AWARD_CLASSES, + "current_year": 2026, + } + + +@app.template_filter("stars") +def stars_filter(value): + """Return a list of 'full' / 'half' / 'empty' for a 1dp rating.""" + if value is None: + return ["off"] * 5 + result = [] + for index in range(1, 6): + if value >= index - 0.25: + result.append("full") + elif value >= index - 0.75: + result.append("half") + else: + result.append("off") + return result + + +@app.template_filter("rating1") +def rating1_filter(value): + return "0" if value is None else f"{value:.1f}" + + +@app.template_filter("miles") +def miles_filter(value): + return f"{value:.2f} miles" + + +@app.template_filter("plural_word") +def plural_word_filter(count: int, singular: str, plural: str | None = None) -> str: + return f"{count} {singular if count == 1 else (plural or singular + 's')}" + + +# --------------------------------------------------------------------------- # +# Public pages +# --------------------------------------------------------------------------- # +@app.route("/") +def index(): + specialties = specialties_for_menu() + top_doctors = ( + Doctor.query.filter(Doctor.avg_rating.isnot(None)) + .order_by(Doctor.avg_rating.desc(), Doctor.ratings_count.desc(), Doctor.id) + .limit(3) + .all() + ) + typeahead = { + "specialty": [{"label": s.singular, "href": url_for("results", q=s.singular)} for s in specialties], + "condition": [{"label": c.name, "href": url_for("results", q=c.name)} for c in Condition.query.order_by(Condition.name).all()], + "practice": [{"label": p.name, "href": url_for("practice_detail", slug=p.slug)} for p in Practice.query.order_by(Practice.name).all()], + } + preset_chips = [ + ("Cardiologist", url_for("results", q="Cardiologist")), + ("Dermatologists who treat children", url_for("results", q="Dermatologist", cid=Condition.query.filter_by(slug="eczema").first().id if Condition.query.filter_by(slug="eczema").first() else None)), + ("Female OBGYNs", url_for("results", q="Obstetrics & Gynecology", gender="f")), + ("Neurologists who take UnitedHealthcare", url_for("results", q="Neurologist UnitedHealthcare")), + ] + return render_template( + "index.html", + specialties=specialties, + top_doctors=top_doctors, + typeahead=typeahead, + preset_chips=preset_chips, + saved_ids=saved_doctor_ids(), + ) + + +@app.route("/results") +def results(): + params = read_search_params() + params["sids_explicit"] = single_arg("sids", "").isdigit() + loc = resolve_location(single_arg("loc", ""), single_arg("zc", ""), single_arg("city", ""), single_arg("state", "")) + params["loc_label"] = loc["label"] + rows = search_doctors(params, loc["city"]) + page = paginate(rows, params["page"]) + term = search_heading_term(params) + return render_template( + "results.html", + params=params, + loc=loc, + page=page, + term=term, + heading_place=loc["label"], + filter_bar=filter_bar_context(params, show_distance=True), + saved_ids=saved_doctor_ids(), + base_path=url_for("results"), + page_qs=lambda n: filter_query_string(params, page=n), + ) + + +def doctor_or_404(slug: str) -> Doctor: + doctor = Doctor.query.filter_by(slug=slug).first() + if doctor is None: + abort(404) + return doctor + + +@app.route("/doctor/-locations") +@app.route("/doctor/-reviews") +@app.route("/doctor/-insurance") +def doctor_tab_alias(slug: str): + doctor_or_404(slug) + return redirect(url_for("doctor_profile", slug=slug), code=301) + + +@app.route("/doctor/-overview") +def doctor_profile(slug: str): + doctor = doctor_or_404(slug) + primary = doctor.primary_location + reviews_page = paginate(list(doctor.reviews), int_arg("rpage", 1, 1, 10**4), REVIEW_PAGE_SIZE) + if doctor.is_enhanced: + rail_title = "Other Providers at This Practice" + colleagues = practice_colleagues(doctor, primary.practice_id, limit=10) + else: + if doctor.hospital is not None: + rail_title = f"Other Providers at {doctor.hospital.name}" + colleagues = [d for d in doctor.hospital.doctors if d.id != doctor.id][:10] + else: + rail_title = "Other Providers at This Practice" + colleagues = practice_colleagues(doctor, primary.practice_id, limit=10) + nearby_cities = [ + city for city in City.query.order_by(City.state, City.name).all() if city.id != primary.city_id + ] + user_review = None + is_saved = False + if current_user.is_authenticated: + user_review = UserReview.query.filter_by(user_id=current_user.id, doctor_id=doctor.id).order_by(UserReview.id.desc()).first() + is_saved = SavedProvider.query.filter_by(user_id=current_user.id, doctor_id=doctor.id).first() is not None + top_conditions = doctor.conditions[:5] + more_conditions = doctor.conditions[5:] + top_procedures = doctor.procedures[:5] + more_procedures = doctor.procedures[5:] + insurer_names = doctor.insurer_names() + return render_template( + "doctor.html", + doctor=doctor, + primary=primary, + reviews_page=reviews_page, + rail_title=rail_title, + colleagues=colleagues, + nearby_cities=nearby_cities, + user_review=user_review, + is_saved=is_saved, + top_conditions=top_conditions, + more_conditions=more_conditions, + top_procedures=top_procedures, + more_procedures=more_procedures, + insurer_names=insurer_names, + booking_days=BOOKING_DAYS, + booking_slots=BOOKING_SLOTS, + poll_rows=doctor.poll_rows(DOCTOR_POLL_QUESTIONS, doctor.short_name), + fellowships=doctor.education_rows("Fellowship"), + residencies=doctor.education_rows("Residency"), + schools=doctor.education_rows("Medical School"), + base_path=url_for("doctor_profile", slug=slug), + page_qs=lambda n: urlencode({"rpage": n}), + ) + + +def practice_colleagues(doctor: Doctor, practice_id: int, limit: int) -> list[Doctor]: + rows = ( + Location.query.filter(Location.practice_id == practice_id, Location.doctor_id != doctor.id) + .order_by(Location.doctor_id) + .all() + ) + seen: list[Doctor] = [] + for row in rows: + if row.doctor not in seen: + seen.append(row.doctor) + seen.sort(key=lambda d: (d.last_name, d.first_name, d.id)) + return seen[:limit] + + +@app.route("/doctor//bookappointment", methods=["GET", "POST"]) +def book_appointment(slug: str): + doctor = doctor_or_404(slug) + if not doctor.is_enhanced: + abort(404) + if not current_user.is_authenticated: + return redirect(url_for("login", next=url_for("book_appointment", slug=slug))) + errors: list[str] = [] + form = { + "patient_type": request.form.get("patient_type", "").strip(), + "location_id": request.form.get("location_id", "").strip(), + "slot": request.form.get("slot", "").strip(), + } + if request.method == "POST": + location = None + if form["location_id"].isdigit(): + location = next((row for row in doctor.locations if row.id == int(form["location_id"])), None) + if location is None: + errors.append("Choose one of the provider's locations.") + if form["patient_type"] not in PATIENT_TYPES: + errors.append("Tell us whether this appointment is for a new or returning patient.") + slot_date = slot_time = None + if "|" in form["slot"]: + raw_date, raw_time = form["slot"].split("|", 1) + for _abbr, _short, day, _label in BOOKING_DAYS: + if day.isoformat() == raw_date and raw_time in BOOKING_SLOTS: + slot_date, slot_time = day, raw_time + if slot_date is None: + errors.append("Pick an appointment time from the calendar.") + if not errors: + booking = AppointmentRequest( + user_id=current_user.id, + doctor_id=doctor.id, + location_id=location.id, + patient_type=form["patient_type"], + slot_date=slot_date, + slot_time=slot_time, + reference="pending", + created_at=datetime.now(), + ) + db.session.add(booking) + db.session.flush() + booking.reference = confirmation_reference(booking.id) + db.session.commit() + return render_template("book_confirm.html", doctor=doctor, booking=booking) + return render_template( + "book.html", + doctor=doctor, + errors=errors, + form=form, + booking_days=BOOKING_DAYS, + booking_slots=BOOKING_SLOTS, + patient_types=PATIENT_TYPES, + ) + + +@app.route("/doctor//save", methods=["POST"]) +def save_provider(slug: str): + doctor = doctor_or_404(slug) + if not current_user.is_authenticated: + return redirect(url_for("login", next=url_for("doctor_profile", slug=slug))) + target = safe_next(request.form.get("next")) or url_for("doctor_profile", slug=slug) + existing = SavedProvider.query.filter_by(user_id=current_user.id, doctor_id=doctor.id).first() + if existing is not None: + db.session.delete(existing) + db.session.commit() + flash(f"{doctor.full_name} was removed from your saved providers.", "info") + else: + db.session.add(SavedProvider(user_id=current_user.id, doctor_id=doctor.id, saved_at=datetime.now())) + db.session.commit() + flash(f"{doctor.full_name} was saved to your providers.", "success") + return redirect(target) + + +@app.route("/doctor//review", methods=["POST"]) +def submit_review(slug: str): + doctor = doctor_or_404(slug) + if not current_user.is_authenticated: + return redirect(url_for("login", next=url_for("doctor_profile", slug=slug) + "#reviews")) + errors: list[str] = [] + rating_raw = request.form.get("rating", "").strip() + rating = int(rating_raw) if rating_raw.isdigit() else 0 + if rating < 1 or rating > 5: + errors.append("Select a star rating from 1 to 5.") + text = re.sub(r"\s+", " ", request.form.get("text", "")).strip() + if len(text) < MIN_REVIEW_LENGTH: + errors.append(f"Your review must be at least {MIN_REVIEW_LENGTH} characters.") + if len(text) > MAX_REVIEW_LENGTH: + errors.append(f"Your review must be {MAX_REVIEW_LENGTH} characters or fewer.") + criteria: dict[str, int] = {} + for index in range(1, 8): + value = request.form.get(f"c{index}", "").strip() + if value not in ("1", "0"): + errors.append(f"Rate the provider on: {PERSPECTIVE_CRITERIA[index - 1]}.") + else: + criteria[f"c{index}"] = int(value) + if errors: + for error in errors: + flash(error, "error") + return redirect(url_for("doctor_profile", slug=slug) + "#reviews") + review = UserReview( + user_id=current_user.id, + doctor_id=doctor.id, + rating=rating, + text=text, + status="Pending review", + created_at=datetime.now(), + **criteria, + ) + db.session.add(review) + db.session.commit() + flash("Thanks — your review is pending", "success") + return redirect(url_for("doctor_profile", slug=slug) + "#reviews") + + +@app.route("/providers") +def providers_alias(): + return redirect(url_for("specialty_index"), code=301) + + +@app.route("/providers/specialty") +def specialty_index(): + specialties = Specialty.query.order_by(Specialty.name).all() + return render_template("specialty_index.html", specialties=specialties) + + +def specialty_or_404(spec: str) -> Specialty: + specialty = Specialty.query.filter_by(slug=spec).first() + if specialty is None: + abort(404) + return specialty + + +def specialty_doctor_rows(specialty: Specialty): + query = ( + db.session.query(City.state, City.state_name, City.state_slug, City.name, City.slug, City.id) + .select_from(Doctor) + .join(Location, db.and_(Location.doctor_id == Doctor.id, Location.is_primary.is_(True))) + .join(City, Location.city_id == City.id) + .filter(Doctor.primary_specialty_id == specialty.id) + ) + return query.all() + + +def highest_rated_near_anchor(specialty: Specialty, anchor: City, limit: int) -> list[Doctor]: + """Top-rated doctors of a specialty inside the default search radius of the anchor.""" + rated = ( + Doctor.query.filter(Doctor.primary_specialty_id == specialty.id, Doctor.avg_rating.isnot(None)) + .order_by(Doctor.avg_rating.desc(), Doctor.ratings_count.desc(), Doctor.id) + .all() + ) + nearby = [] + for doctor in rated: + location = doctor.primary_location + if haversine_miles(anchor.lat, anchor.lon, location.lat, location.lon) <= DEFAULT_DISTANCE: + nearby.append(doctor) + if len(nearby) == limit: + break + return nearby + + +@app.route("/providers/specialty/") +def specialty_landing(spec: str): + specialty = specialty_or_404(spec) + rows = specialty_doctor_rows(specialty) + states = states_with_counts([(r[0], r[1], r[2]) for r in rows]) + cities: dict = {} + for state, _sn, state_slug, city_name, city_slug, _cid in rows: + entry = cities.setdefault((state, city_name), {"state": state, "state_slug": state_slug, "name": city_name, "slug": city_slug, "count": 0}) + entry["count"] += 1 + city_chips = sorted(cities.values(), key=lambda entry: entry["name"]) + anchor = anchor_city() + highest_rated = highest_rated_near_anchor(specialty, anchor, limit=5) + rated = [d for d in Doctor.query.filter(Doctor.primary_specialty_id == specialty.id, Doctor.avg_rating.isnot(None)).all()] + average_rating = round(sum(d.avg_rating for d in rated) / len(rated), 1) if rated else None + total_ratings = sum(d.ratings_count for d in rated) + conditions = Condition.query.filter_by(specialty_id=specialty.id).order_by(Condition.id).all() + procedures = Procedure.query.filter_by(specialty_id=specialty.id).order_by(Procedure.id).all() + return render_template( + "specialty_landing.html", + specialty=specialty, + states=states, + city_chips=city_chips, + highest_rated=highest_rated, + total=len(rows), + average_rating=average_rating, + total_ratings=total_ratings, + conditions=conditions, + procedures=procedures, + anchor=anchor, + saved_ids=saved_doctor_ids(), + ) + + +@app.route("/providers/specialty//") +def specialty_state(spec: str, state: str): + specialty = specialty_or_404(spec) + city = City.query.filter_by(state_slug=state).order_by(City.id).first() + if city is None: + abort(404) + rows = specialty_doctor_rows(specialty) + state_rows = [r for r in rows if r[2] == state] + cities: dict = {} + for _st, _sn, _ss, city_name, city_slug, _cid in state_rows: + entry = cities.setdefault(city_name, {"name": city_name, "slug": city_slug, "count": 0}) + entry["count"] += 1 + params = read_search_params({"sids": specialty.id, "state": city.state, "use_distance": False}) + params["loc_label"] = "" + ranked = search_doctors(params, anchor_city()) + page = paginate(ranked, params["page"]) + return render_template( + "specialty_state.html", + specialty=specialty, + state_code=city.state, + state_name=city.state_name, + state_slug=state, + cities=sorted(cities.values(), key=lambda entry: entry["name"]), + page=page, + params=params, + filter_bar=filter_bar_context(params, show_distance=False), + saved_ids=saved_doctor_ids(), + base_path=url_for("specialty_state", spec=spec, state=state), + total=len(state_rows), + page_qs=lambda n: filter_query_string(params, page=n), + ) + + +@app.route("/providers/specialty///") +def specialty_city(spec: str, state: str, city: str): + specialty = specialty_or_404(spec) + city_row = City.query.filter_by(state_slug=state, slug=city).first() + if city_row is None: + abort(404) + params = read_search_params({"sids": specialty.id, "city_id": city_row.id, "use_distance": False}) + params["loc_label"] = "" + ranked = search_doctors(params, city_row) + page = paginate(ranked, params["page"]) + conditions = Condition.query.filter_by(specialty_id=specialty.id).order_by(Condition.id).limit(3).all() + procedures = Procedure.query.filter_by(specialty_id=specialty.id).order_by(Procedure.id).limit(3).all() + all_rows = search_doctors(read_search_params({"sids": specialty.id, "city_id": city_row.id, "use_distance": False}) | {"page": 1}, city_row) + experience = [row["doctor"].years_experience for row in all_rows] + average_experience = round(sum(experience) / len(experience)) if experience else 0 + total_reviews = sum(row["doctor"].ratings_count for row in all_rows) + accepting = sum(1 for row in all_rows if row["doctor"].accepting_new_patients) + return render_template( + "specialty_city.html", + specialty=specialty, + city=city_row, + page=page, + params=params, + filter_bar=filter_bar_context(params, show_distance=False), + saved_ids=saved_doctor_ids(), + base_path=url_for("specialty_city", spec=spec, state=state, city=city), + page_qs=lambda n: filter_query_string(params, page=n), + total=len(all_rows), + average_experience=average_experience, + total_reviews=total_reviews, + accepting=accepting, + conditions=conditions, + procedures=procedures, + ) + + +def hub_page(kind: str, state_slug: str | None): + model = Hospital if kind == "hospitals" else Practice + items = model.query.all() + state_name = None + state_code = None + if state_slug is not None: + city = City.query.filter_by(state_slug=state_slug).order_by(City.id).first() + if city is None: + abort(404) + state_name, state_code = city.state_name, city.state + items = [item for item in items if item.city.state == state_code] + states = states_with_counts([(item.city.state, item.city.state_name, item.city.state_slug) for item in model.query.all()]) + sortby = single_arg("sortby", "bestmatch") + if sortby not in {key for key, _label in HUB_SORT_OPTIONS}: + sortby = "bestmatch" + minrating = int_arg("minrating", 0, 1, 5) if single_arg("minrating", "").isdigit() else 0 + ordered = hub_sorted(items, sortby, minrating) + page = paginate(ordered, int_arg("page", 1, 1, 10**4)) + counts = {} + for item in page["rows"]: + if kind == "hospitals": + doctors = item.doctors + else: + doctors = list({loc.doctor_id: loc.doctor for loc in item.locations}.values()) + counts[item.id] = { + "physicians": len(doctors), + "specialties": len({d.primary_specialty_id for d in doctors}), + } + return render_template( + "hub_list.html", + kind=kind, + title="Hospitals" if kind == "hospitals" else "Group Practices", + noun="Hospital" if kind == "hospitals" else "Group Practice", + state_name=state_name, + state_code=state_code, + state_slug=state_slug, + states=states, + page=page, + counts=counts, + sortby=sortby, + minrating=minrating, + city_count=len({item.city_id for item in items}), + total=len(items), + base_path=url_for(f"{kind}_index") if state_slug is None else url_for(f"{kind}_state", state=state_slug), + page_qs=lambda n: urlencode({k: v for k, v in (("sortby", sortby if sortby != "bestmatch" else ""), ("minrating", minrating or ""), ("page", n)) if v not in ("", None)}), + ) + + +@app.route("/hospitals") +def hospitals_index(): + return hub_page("hospitals", None) + + +@app.route("/hospitals/") +def hospitals_state(state: str): + return hub_page("hospitals", state) + + +@app.route("/grouppractices") +def grouppractices_index(): + return hub_page("grouppractices", None) + + +@app.route("/grouppractices/") +def grouppractices_state(state: str): + return hub_page("grouppractices", state) + + +def specialty_counts(doctors: list[Doctor]) -> list[tuple[str, int]]: + counts: dict[str, int] = {} + for doctor in doctors: + counts[doctor.primary_specialty.name] = counts.get(doctor.primary_specialty.name, 0) + 1 + return sorted(counts.items()) + + +@app.route("/hospital/") +def hospital_detail(slug: str): + hospital = Hospital.query.filter_by(slug=slug).first() + if hospital is None: + abort(404) + doctors = sorted(hospital.doctors, key=lambda d: (d.last_name.lower(), d.first_name.lower(), d.id)) + page = paginate(doctors, int_arg("pagenumber", 1, 1, 10**4)) + return render_template( + "hospital.html", + hospital=hospital, + page=page, + doctors=doctors, + specialty_rows=specialty_counts(doctors), + poll_rows=hospital.poll_rows(HOSPITAL_POLL_QUESTIONS), + top_specialties=[name for name, _count in sorted(specialty_counts(doctors), key=lambda row: (-row[1], row[0]))[:4]], + award_count=sum(len(d.awards) for d in doctors), + base_path=url_for("hospital_detail", slug=slug), + page_qs=lambda n: urlencode({"pagenumber": n}), + ) + + +@app.route("/practice/") +def practice_detail(slug: str): + practice = Practice.query.filter_by(slug=slug).first() + if practice is None: + abort(404) + by_id: dict[int, Doctor] = {} + for location in practice.locations: + by_id.setdefault(location.doctor_id, location.doctor) + doctors = sorted(by_id.values(), key=lambda d: (d.last_name.lower(), d.first_name.lower(), d.id)) + page = paginate(doctors, int_arg("pagenumber", 1, 1, 10**4)) + insurers: list[str] = [] + for doctor in doctors: + for name in doctor.insurer_names(): + if name not in insurers: + insurers.append(name) + insurers.sort() + primary_locations = [loc for loc in practice.locations if loc.is_primary] + flags = { + "medicare": any(loc.medicare for loc in primary_locations), + "medicaid": any(loc.medicaid for loc in primary_locations), + "new_patients": any(loc.new_patients for loc in primary_locations), + } + return render_template( + "practice.html", + practice=practice, + page=page, + doctors=doctors, + specialty_rows=specialty_counts(doctors), + insurers=insurers, + flags=flags, + poll_rows=practice.poll_rows(PRACTICE_POLL_QUESTIONS), + top_specialties=[name for name, _count in sorted(specialty_counts(doctors), key=lambda row: (-row[1], row[0]))[:3]], + base_path=url_for("practice_detail", slug=slug), + page_qs=lambda n: urlencode({"pagenumber": n}), + ) + + +@app.route("/choice-awards") +def choice_awards(): + counts = {key: Award.query.filter_by(award_class=value[0]).count() for key, value in AWARD_CLASSES.items()} + return render_template("awards.html", counts=counts) + + +@app.route("/choice-awards/awardrecipients") +def award_recipients(): + award_class = single_arg("award-class", "patient").lower() + if award_class not in AWARD_CLASSES: + award_class = "patient" + class_name, line, title = AWARD_CLASSES[award_class] + awards = Award.query.filter_by(award_class=class_name).all() + doctors = sorted({award.doctor_id: award.doctor for award in awards}.values(), key=lambda d: (d.last_name.lower(), d.first_name.lower(), d.id)) + page = paginate(doctors, int_arg("page", 1, 1, 10**4)) + years = {award.doctor_id: award.year for award in awards} + return render_template( + "award_recipients.html", + award_class=award_class, + class_title=title, + award_line=line, + page=page, + years=years, + saved_ids=saved_doctor_ids(), + base_path=url_for("award_recipients"), + page_qs=lambda n: urlencode({"award-class": award_class, "page": n}), + ) + + +@app.route("/reviews-guidelines") +def reviews_guidelines(): + return render_template("guidelines.html") + + +# --------------------------------------------------------------------------- # +# Auth + account +# --------------------------------------------------------------------------- # +@app.route("/login", methods=["GET", "POST"]) +def login(): + next_url = safe_next(request.args.get("next")) + errors: list[str] = [] + email = "" + if current_user.is_authenticated and request.method == "GET": + return redirect(next_url or url_for("index")) + if request.method == "POST": + next_url = safe_next(request.form.get("next")) or next_url + email = request.form.get("email", "").strip().lower()[:160] + password = request.form.get("password", "") + user = User.query.filter_by(email=email).first() if email else None + if user is None or not check_password_hash(user.password_hash, password): + errors.append("The email or password you entered is incorrect.") + else: + login_user(user, remember=request.form.get("remember") == "on") + return redirect(next_url or url_for("index")) + return render_template("login.html", errors=errors, email=email, next_url=next_url) + + +@app.route("/signup", methods=["GET", "POST"]) +def signup(): + next_url = safe_next(request.args.get("next")) + errors: list[str] = [] + form = {"email": "", "dob": ""} + if request.method == "POST": + next_url = safe_next(request.form.get("next")) or next_url + form["email"] = request.form.get("email", "").strip().lower()[:160] + form["dob"] = request.form.get("dob", "").strip() + password = request.form.get("password", "") + if not EMAIL_PATTERN.match(form["email"]): + errors.append("Enter a valid email address.") + if len(password) < MIN_PASSWORD_LENGTH: + errors.append(f"Your password must be at least {MIN_PASSWORD_LENGTH} characters.") + if len(password) > 256: + errors.append("Your password must be 256 characters or fewer.") + dob = None + if form["dob"]: + try: + dob = date.fromisoformat(form["dob"]) + except ValueError: + errors.append("Enter your date of birth as YYYY-MM-DD.") + if not errors: + user = User( + email=form["email"], + password_hash=generate_password_hash(password), + dob=dob, + display_name=form["email"].split("@", 1)[0], + created_at=datetime.now(), + ) + db.session.add(user) + try: + db.session.commit() + except IntegrityError: + db.session.rollback() + errors.append("An account with that email already exists. Log in instead.") + else: + login_user(user) + return redirect(next_url or url_for("index")) + return render_template("signup.html", errors=errors, form=form, next_url=next_url) + + +@app.route("/logout", methods=["POST"]) +@login_required +def logout(): + logout_user() + return redirect(url_for("index")) + + +@app.route("/account/saved") +@login_required +def account_saved(): + rows = ( + SavedProvider.query.filter_by(user_id=current_user.id) + .order_by(SavedProvider.saved_at.desc(), SavedProvider.id.desc()) + .all() + ) + return render_template("account_saved.html", rows=rows, saved_ids={row.doctor_id for row in rows}) + + +@app.route("/account/saved//remove", methods=["POST"]) +@login_required +def account_saved_remove(slug: str): + doctor = doctor_or_404(slug) + row = SavedProvider.query.filter_by(user_id=current_user.id, doctor_id=doctor.id).first() + if row is not None: + db.session.delete(row) + db.session.commit() + flash(f"{doctor.full_name} was removed from your saved providers.", "info") + return redirect(url_for("account_saved")) + + +@app.route("/account/appointments") +@login_required +def account_appointments(): + rows = ( + AppointmentRequest.query.filter_by(user_id=current_user.id) + .order_by(AppointmentRequest.created_at.desc(), AppointmentRequest.id.desc()) + .all() + ) + return render_template("account_appointments.html", rows=rows) + + +@app.route("/health") +def health(): + marker = db.session.get(SeedMetadata, "version") + counts = { + "doctors": Doctor.query.count(), + "specialties": Specialty.query.count(), + "hospitals": Hospital.query.count(), + "practices": Practice.query.count(), + "users": User.query.count(), + } + ready = counts["doctors"] > 0 and marker is not None and marker.value == SEED_VERSION + return jsonify({"ok": ready, "site": "webmd_doctor", "seed_version": marker.value if marker else None, **counts}), (200 if ready else 503) + + +@app.errorhandler(404) +def not_found(_error): + return render_template("404.html"), 404 + + +@app.errorhandler(500) +def server_error(_error): # pragma: no cover - defensive + db.session.rollback() + return render_template("500.html"), 500 + + +def bootstrap_site() -> None: + from seed_data import ensure_seed_database + + with app.app_context(): + db.create_all() + ensure_seed_database() + + +# `python app.py` loads this file as __main__; register it under its import +# name too so seed_data's `from app import ...` reuses this module instead of +# building a second Flask app + SQLAlchemy instance. +sys.modules.setdefault("app", sys.modules[__name__]) + +if os.environ.get("WEBSYN_SKIP_BOOTSTRAP") != "1": + bootstrap_site() + + +if __name__ == "__main__": + port = int(os.environ.get("PORT", "5000")) + app.run(host="0.0.0.0", port=port, debug=False) diff --git a/sites/webmd_doctor/requirements.txt b/sites/webmd_doctor/requirements.txt new file mode 100644 index 00000000..02bbaea7 --- /dev/null +++ b/sites/webmd_doctor/requirements.txt @@ -0,0 +1,7 @@ +Flask==3.1.0 +Flask-SQLAlchemy==3.1.1 +Flask-Login==0.6.3 +Flask-WTF==1.2.2 +Werkzeug==3.1.3 +SQLAlchemy==2.0.36 +Pillow==11.0.0 diff --git a/sites/webmd_doctor/seed_data.py b/sites/webmd_doctor/seed_data.py new file mode 100644 index 00000000..987eb6a9 --- /dev/null +++ b/sites/webmd_doctor/seed_data.py @@ -0,0 +1,1323 @@ +"""Deterministic seed for the WebMD Doctor mirror. + +Run directly (`PYTHONHASHSEED=0 python seed_data.py`) to rebuild +`instance_seed/webmd_doctor.db`, `static/images/avatars/*.png` and +`static/images/posters/*.png`. Byte-reproducible: one seeded RNG, no +wall-clock reads, sorted iteration only, hard-coded password hashes. + +Every doctor, practice, hospital, address, phone, NPI, school and review is +synthetic. Real city / state / specialty / insurer names are reused only as +vocabulary. +""" +from __future__ import annotations + +import importlib.util +import os +import random +import shutil +from datetime import date, datetime, timedelta +from pathlib import Path + +from sqlalchemy import text + +os.environ.setdefault("WEBSYN_SKIP_BOOTSTRAP", "1") + +from app import ( # noqa: E402 + AppointmentRequest, + Award, + Certification, + City, + CityZip, + Condition, + Doctor, + DoctorCondition, + DoctorExpertise, + DoctorInsurance, + DoctorLanguage, + DoctorPerspective, + DoctorProcedure, + Education, + ExpertiseArea, + Hospital, + InsurancePlan, + Insurer, + License, + Location, + PERSPECTIVE_CRITERIA, + Practice, + Procedure, + Review, + SEED_VERSION, + SavedProvider, + SeedMetadata, + Specialty, + User, + UserReview, + app, + confirmation_reference, + db, +) + +SEED = 20260910 +RNG = random.Random(SEED) +MIRROR_REFERENCE_DATE = date(2026, 9, 10) +BASE_DIR = Path(__file__).resolve().parent +DB_PATH = BASE_DIR / "instance" / "webmd_doctor.db" +INSTANCE_SEED_DIR = BASE_DIR / "instance_seed" +AVATAR_DIR = BASE_DIR / "static" / "images" / "avatars" +POSTER_DIR = BASE_DIR / "static" / "images" / "posters" + +# Hard-coded werkzeug scrypt hashes of "TestPass123!" (generate_password_hash +# salts randomly, so recomputing them would break byte-identical rebuilds). +DEMO_PASSWORD_HASHES = { + "alice.j@test.com": "scrypt:32768:8:1$x9JMG7iKsrRO1AGh$e8e195799326a6e1ff55d4d20dd2735d9d68c0ed4879bd6b263ac33fa9953b0f6c8505680f1a1d8a9fbe9b0d01ef5e88f99b77b89fc30299fda217185a3b7acf", + "bob.c@test.com": "scrypt:32768:8:1$O14LIVdpqb3Q6D7E$4b9389cd00aad4417058fd629af5bf979b3f05bdf011791fbc65b7080fe898e50c7aedc2c22be92c71ae25a1df6922bb4ca44b386a7f17040b36888c0cdc8942", + "carol.d@test.com": "scrypt:32768:8:1$9PGtFS6I89BOEugS$890b1c1935bb6c0c4a6f7f5ad689cc02415e4bd03b02e101f0c2095931d4f157a8504c0fa4f12c3073c94e1480fea3305ffbadc5e8540c5eaf1c16965cb47be7", + "david.k@test.com": "scrypt:32768:8:1$luqs3gbiT1hPpw2c$09f31fef9514ae90d234cdd91a7f2c95d937e08a37fed60ce0b41755a097609040f7aa5083b94ecdd41804262dc778bd6f8d8e9f38f47f319fa8d6f3ed705d1b", +} +BENCHMARK_USERS = [ + ("alice.j@test.com", "Alice Johnson", date(1988, 4, 12)), + ("bob.c@test.com", "Bob Chen", date(1979, 11, 3)), + ("carol.d@test.com", "Carol Davis", date(1993, 7, 21)), + ("david.k@test.com", "David Kim", date(1984, 2, 9)), +] +USER_CREATED_AT = datetime(2026, 6, 12, 9, 30, 0) + +# Filled in from the frozen seed; ensure_seed_database refuses partial DBs. +EXPECTED_COUNTS = { + "specialties": 10, + "conditions": 40, + "procedures": 30, + "expertise_areas": 40, + "insurers": 12, + "insurance_plans": 28, + "cities": 8, + "city_zips": 24, + "hospitals": 12, + "practices": 30, + "doctors": 224, + "locations": 345, + "doctor_conditions": 1667, + "doctor_procedures": 1241, + "doctor_expertise": 681, + "doctor_insurances": 2244, + "reviews": 1195, + "doctor_perspectives": 1568, + "certifications": 293, + "licenses": 314, + "education": 562, + "awards": 50, + "doctor_languages": 310, + "users": 4, + "saved_providers": 4, + "appointment_requests": 1, + "user_reviews": 1, +} +CORE_COUNT_KEYS = ("specialties", "cities", "hospitals", "practices", "doctors", "doctor_perspectives") + +# --------------------------------------------------------------------------- # +# Vocabularies (real specialties / insurers / cities as vocabulary only) +# --------------------------------------------------------------------------- # +SPECIALTIES = [ + # name, slug, singular, plural, board, cert type, subspecialty cert, description + ("Dermatology", "dermatology", "Dermatologist", "Dermatologists", "American Board of Dermatology", "Dermatology", "Pediatric Dermatology", + "Dermatologists diagnose and treat conditions of the skin, hair and nails, from acne and eczema to skin cancer screening and cosmetic procedures."), + ("Cardiovascular Disease", "cardiovascular-disease", "Cardiologist", "Cardiologists", "American Board of Internal Medicine", "Cardiovascular Disease", "Interventional Cardiology", + "Cardiologists care for the heart and blood vessels, managing coronary artery disease, heart rhythm problems, heart failure and high blood pressure."), + ("Family Medicine", "family-medicine", "Family Physician", "Family Physicians", "American Board of Family Medicine", "Family Medicine", "Geriatric Medicine", + "Family physicians provide continuing, comprehensive care for patients of every age, from preventive visits to the management of chronic illness."), + ("Neurology", "neurology", "Neurologist", "Neurologists", "American Board of Psychiatry and Neurology", "Neurology", "Vascular Neurology", + "Neurologists treat disorders of the brain, spinal cord and nerves, including migraine, epilepsy, multiple sclerosis and movement disorders."), + ("Orthopedic Surgery", "orthopedic-surgery", "Orthopedic Surgeon", "Orthopedic Surgeons", "American Board of Orthopaedic Surgery", "Orthopaedic Surgery", "Orthopaedic Sports Medicine", + "Orthopedic surgeons treat injuries and diseases of the bones, joints, ligaments and tendons, from sports injuries to joint replacement."), + ("Gastroenterology", "gastroenterology", "Gastroenterologist", "Gastroenterologists", "American Board of Internal Medicine", "Gastroenterology", "Transplant Hepatology", + "Gastroenterologists diagnose and treat conditions of the digestive tract and liver, and perform screening procedures such as colonoscopy."), + ("Psychiatry", "psychiatry", "Psychiatrist", "Psychiatrists", "American Board of Psychiatry and Neurology", "Psychiatry", "Child and Adolescent Psychiatry", + "Psychiatrists are physicians who diagnose and treat mental health conditions such as depression, anxiety, bipolar disorder and ADHD."), + ("Obstetrics & Gynecology", "obstetrics-gynecology", "OBGYN", "OBGYNs", "American Board of Obstetrics and Gynecology", "Obstetrics and Gynecology", "Maternal-Fetal Medicine", + "OBGYNs provide care for pregnancy and childbirth and treat conditions of the female reproductive system across every stage of life."), + ("Pediatrics", "pediatrics", "Pediatrician", "Pediatricians", "American Board of Pediatrics", "Pediatrics", "Pediatric Emergency Medicine", + "Pediatricians care for infants, children and adolescents, providing well-child visits, immunizations and treatment of childhood illness."), + ("Internal Medicine", "internal-medicine", "Internist", "Internists", "American Board of Internal Medicine", "Internal Medicine", "Geriatric Medicine", + "Internists are primary care physicians for adults, focusing on prevention and the diagnosis and management of chronic conditions."), +] +# Specialties whose conditions / procedures a doctor may also list (cross-field consistency). +RELATED_SPECIALTIES = { + "Dermatology": ["Internal Medicine", "Family Medicine"], + "Cardiovascular Disease": ["Internal Medicine", "Family Medicine"], + "Family Medicine": ["Internal Medicine", "Pediatrics", "Dermatology", "Cardiovascular Disease", "Gastroenterology", "Psychiatry", "Obstetrics & Gynecology"], + "Neurology": ["Psychiatry", "Internal Medicine"], + "Orthopedic Surgery": ["Family Medicine", "Internal Medicine"], + "Gastroenterology": ["Internal Medicine", "Family Medicine"], + "Psychiatry": ["Neurology", "Family Medicine", "Internal Medicine"], + "Obstetrics & Gynecology": ["Family Medicine", "Internal Medicine"], + "Pediatrics": ["Family Medicine", "Internal Medicine"], + "Internal Medicine": ["Family Medicine", "Cardiovascular Disease", "Gastroenterology", "Dermatology", "Neurology", "Psychiatry"], +} +SECONDARY_CHOICES = { + "Dermatology": ["Internal Medicine"], + "Cardiovascular Disease": ["Internal Medicine"], + "Family Medicine": ["Internal Medicine", "Pediatrics"], + "Neurology": ["Psychiatry", "Internal Medicine"], + "Orthopedic Surgery": ["Family Medicine"], + "Gastroenterology": ["Internal Medicine"], + "Psychiatry": ["Neurology", "Family Medicine"], + "Obstetrics & Gynecology": ["Family Medicine"], + "Pediatrics": ["Family Medicine", "Internal Medicine"], + "Internal Medicine": ["Family Medicine", "Cardiovascular Disease", "Gastroenterology"], +} +CONDITIONS = { + "Dermatology": ["Acne", "Eczema", "Psoriasis", "Rosacea"], + "Cardiovascular Disease": ["Coronary Artery Disease", "Atrial Fibrillation", "Heart Failure", "Hypertension"], + "Family Medicine": ["Type 2 Diabetes", "High Cholesterol", "Sinusitis", "Back Pain"], + "Neurology": ["Migraine", "Epilepsy", "Multiple Sclerosis", "Parkinson's Disease"], + "Orthopedic Surgery": ["Osteoarthritis of the Knee", "Rotator Cuff Tear", "ACL Injury", "Hip Fracture"], + "Gastroenterology": ["Acid Reflux (GERD)", "Irritable Bowel Syndrome", "Crohn's Disease", "Celiac Disease"], + "Psychiatry": ["Major Depressive Disorder", "Generalized Anxiety Disorder", "Bipolar Disorder", "ADHD"], + "Obstetrics & Gynecology": ["Endometriosis", "Polycystic Ovary Syndrome", "Uterine Fibroids", "Menopause"], + "Pediatrics": ["Asthma in Children", "Ear Infection", "Childhood Obesity", "Strep Throat"], + "Internal Medicine": ["Anemia", "Hypothyroidism", "Chronic Kidney Disease", "Osteoporosis"], +} +PROCEDURES = { + "Dermatology": ["Skin Biopsy", "Mohs Surgery", "Cryotherapy for Skin Lesions"], + "Cardiovascular Disease": ["Echocardiogram", "Cardiac Catheterization", "Stress Test"], + "Family Medicine": ["Annual Physical Exam", "Flu Vaccination", "Joint Injection"], + "Neurology": ["EEG (Electroencephalogram)", "EMG (Electromyography)", "Lumbar Puncture"], + "Orthopedic Surgery": ["Knee Arthroscopy", "Total Hip Replacement", "Carpal Tunnel Release"], + "Gastroenterology": ["Colonoscopy", "Upper Endoscopy", "Capsule Endoscopy"], + "Psychiatry": ["Psychotherapy", "Medication Management", "Transcranial Magnetic Stimulation"], + "Obstetrics & Gynecology": ["Pap Smear", "Pelvic Ultrasound", "IUD Insertion"], + "Pediatrics": ["Well-Child Visit", "Childhood Immunization", "Vision Screening"], + "Internal Medicine": ["Blood Pressure Screening", "Diabetes Management", "Cholesterol Screening"], +} +EXPERTISE = { + "Dermatology": ["Cosmetic Dermatology", "Skin Cancer Screening", "Pediatric Dermatology", "Laser Treatments"], + "Cardiovascular Disease": ["Preventive Cardiology", "Heart Rhythm Disorders", "Cardiac Imaging", "Structural Heart Disease"], + "Family Medicine": ["Preventive Care", "Chronic Disease Management", "Women's Health", "Sports Physicals"], + "Neurology": ["Headache Medicine", "Movement Disorders", "Stroke Care", "Neuromuscular Disorders"], + "Orthopedic Surgery": ["Sports Medicine", "Joint Replacement", "Hand Surgery", "Spine Care"], + "Gastroenterology": ["Liver Disease", "Inflammatory Bowel Disease", "Colon Cancer Screening", "Motility Disorders"], + "Psychiatry": ["Mood Disorders", "Anxiety Disorders", "Addiction Psychiatry", "Geriatric Psychiatry"], + "Obstetrics & Gynecology": ["Prenatal Care", "Minimally Invasive Gynecologic Surgery", "Fertility Evaluation", "Menopause Management"], + "Pediatrics": ["Newborn Care", "Adolescent Medicine", "Developmental Pediatrics", "Pediatric Asthma Care"], + "Internal Medicine": ["Diabetes Care", "Hypertension Management", "Geriatric Medicine", "Travel Medicine"], +} +# name, slug, weight (relative acceptance), plan types +INSURERS = [ + ("Aetna", "aetna", 140, ["", "HMO", "PPO"]), + ("Cigna", "cigna", 110, ["", "PPO"]), + ("UnitedHealthcare", "unitedhealthcare", 130, ["", "HMO", "Medicare"]), + ("Blue Cross Blue Shield", "blue-cross-blue-shield", 150, ["", "PPO", "HMO"]), + ("Humana", "humana", 90, ["", "Medicare", "PPO"]), + ("Medicare", "medicare", 160, ["", "Medicare"]), + ("Medicaid", "medicaid", 90, ["Medicaid (Managed)"]), + ("AmeriHealth", "amerihealth", 80, ["", "HMO", "PPO"]), + ("Highmark", "highmark", 60, ["", "PPO"]), + ("Horizon", "horizon", 70, ["", "HMO"]), + ("Kaiser Permanente", "kaiser-permanente", 40, ["", "HMO"]), + ("Tricare", "tricare", 45, ["", "Medicare"]), +] +# name, slug, state, state_name, state_slug, lat, lon, zips, doctor count, area code +CITIES = [ + ("Newark", "newark", "DE", "Delaware", "delaware", 39.6837, -75.7497, ["19711", "19702", "19713"], 52, "302"), + ("Bear", "bear", "DE", "Delaware", "delaware", 39.6200, -75.6500, ["19701", "19706", "19720"], 22, "302"), + ("Wilmington", "wilmington", "DE", "Delaware", "delaware", 39.7459, -75.5466, ["19801", "19803", "19805"], 34, "302"), + ("Elkton", "elkton", "MD", "Maryland", "maryland", 39.5700, -75.9200, ["21921", "21922", "21919"], 26, "410"), + ("Salem", "salem", "NJ", "New Jersey", "new-jersey", 39.5718, -75.4671, ["08079", "08070", "08072"], 22, "856"), + ("West Chester", "west-chester", "PA", "Pennsylvania", "pennsylvania", 39.9607, -75.6055, ["19380", "19382", "19383"], 24, "610"), + ("Media", "media", "PA", "Pennsylvania", "pennsylvania", 39.9200, -75.3400, ["19063", "19086", "19091"], 20, "610"), + ("Baltimore", "baltimore", "MD", "Maryland", "maryland", 39.2904, -76.6122, ["21201", "21218", "21224"], 24, "410"), +] +IN_RADIUS_CITIES = ["Newark", "Bear", "Wilmington", "Elkton", "Salem", "West Chester", "Media"] +# (specialty, city) -> number of primary offices; cells named in tasks are >= 6. +CLUSTERS = { + "Dermatology": {"Newark": 6, "Wilmington": 6, "Elkton": 6, "Salem": 1, "West Chester": 1}, + "Cardiovascular Disease": {"Newark": 7, "Wilmington": 6, "West Chester": 6, "Salem": 1}, + "Family Medicine": {"Newark": 8, "Bear": 6, "Elkton": 2, "Salem": 2, "Media": 2}, + "Neurology": {"West Chester": 6, "Newark": 6, "Wilmington": 3, "Elkton": 2, "Salem": 3}, + "Orthopedic Surgery": {"Elkton": 6, "Media": 6, "Newark": 3, "Bear": 3, "Salem": 2}, + "Gastroenterology": {"Newark": 6, "Wilmington": 4, "Bear": 3, "Elkton": 2, "West Chester": 2, "Salem": 3}, + "Psychiatry": {"Media": 6, "Wilmington": 6, "Newark": 4, "Elkton": 2, "Salem": 2}, + "Obstetrics & Gynecology": {"Salem": 6, "Newark": 4, "Bear": 4, "Wilmington": 3, "Media": 3}, + "Pediatrics": {"Newark": 6, "Bear": 3, "Wilmington": 3, "Elkton": 2, "West Chester": 3, "Salem": 1, "Media": 2}, + "Internal Medicine": {"Newark": 2, "Bear": 3, "Wilmington": 3, "Elkton": 4, "West Chester": 6, "Salem": 1, "Media": 1}, +} +BALTIMORE_PER_SPECIALTY = { + "Dermatology": 3, "Cardiovascular Disease": 2, "Family Medicine": 3, "Neurology": 2, "Orthopedic Surgery": 2, + "Gastroenterology": 2, "Psychiatry": 3, "Obstetrics & Gynecology": 2, "Pediatrics": 3, "Internal Medicine": 2, +} +# name, city, street, zip, phone suffix, website slug +HOSPITALS = [ + ("Christina Creek Medical Center", "Newark", "1200 Ogletown Stanton Rd", "19713", "555-0140", "christinacreekmed"), + ("White Clay Regional Hospital", "Newark", "88 Possum Park Rd", "19711", "555-0152", "whiteclayregional"), + ("Red Lion Community Hospital", "Bear", "2400 Pulaski Hwy", "19701", "555-0163", "redlioncommunity"), + ("Brandywine Valley Hospital", "Wilmington", "501 W 14th St", "19801", "555-0171", "brandywinevalleyhospital"), + ("Riverfront General Hospital", "Wilmington", "1600 Rockford Rd", "19803", "555-0184", "riverfrontgeneral"), + ("Cecil Crossing Medical Center", "Elkton", "106 Bow St", "21921", "555-0195", "cecilcrossingmed"), + ("Fenwick Creek Hospital", "Salem", "310 Woodstown Rd", "08079", "555-0207", "fenwickcreekhospital"), + ("Chester Valley Medical Center", "West Chester", "701 E Marshall St", "19380", "555-0218", "chestervalleymed"), + ("Rose Tree Medical Center", "Media", "1068 W Baltimore Pike", "19063", "555-0229", "rosetreemed"), + ("Patapsco Harbor Medical Center", "Baltimore", "2401 W Belvedere Ave", "21218", "555-0231", "patapscoharbormed"), + ("Chesapeake Lantern Hospital", "Baltimore", "900 S Caton Ave", "21224", "555-0242", "chesapeakelantern"), + ("Iron Hill Surgical Hospital", "Newark", "4000 Chapman Rd", "19702", "555-0253", "ironhillsurgical"), +] +PRACTICE_NAME_BY_FOCUS = { + "Dermatology": "{place} Dermatology Associates", + "Cardiovascular Disease": "{place} Heart & Vascular", + "Family Medicine": "{place} Family Health", + "Neurology": "{place} Neurology Group", + "Orthopedic Surgery": "{place} Orthopedics & Sports Medicine", + "Gastroenterology": "{place} Digestive Health", + "Psychiatry": "{place} Behavioral Health", + "Obstetrics & Gynecology": "{place} Women's Health", + "Pediatrics": "{place} Pediatrics", + "Internal Medicine": "{place} Internal Medicine Associates", + None: "{place} Medical Group", +} +# city -> list of (place name, focus specialty or None, street, zip) +PRACTICES = { + "Newark": [ + ("Christina Creek", "Dermatology", "4735 Ogletown Stanton Rd Ste 2200", "19713"), + ("White Clay", "Cardiovascular Disease", "620 Churchmans Rd Ste 110", "19702"), + ("Iron Hill", "Family Medicine", "2600 Glasgow Ave Ste 116", "19702"), + ("Glasgow Pike", "Neurology", "500 Peoples Plz Ste 230", "19702"), + ("Deer Park", "Gastroenterology", "255 E Main St Ste 200", "19711"), + ("Pike Creek", "Pediatrics", "3401 Papermill Rd Ste 5", "19711"), + ("Main Street", None, "112 S Main St Fl 3", "19711"), + ("Ogletown", None, "4051 Ogletown Rd Ste 101", "19713"), + ], + "Bear": [ + ("Red Lion", "Family Medicine", "1200 Pulaski Hwy Ste 1", "19701"), + ("Fox Run", "Orthopedic Surgery", "300 Fox Hunt Dr Ste 220", "19701"), + ("Caravel", None, "1580 Old Porter Rd Ste 3", "19720"), + ], + "Wilmington": [ + ("Brandywine", "Dermatology", "1401 Foulk Rd Ste 201", "19803"), + ("Riverfront", "Cardiovascular Disease", "300 Justison St Ste 400", "19801"), + ("Rockford Park", "Psychiatry", "1800 N Broom St Ste 2", "19805"), + ("Trolley Square", None, "1707 Delaware Ave Ste 100", "19805"), + ], + "Elkton": [ + ("Cecil Crossing", "Dermatology", "142 E Main St Ste 3", "21921"), + ("Big Elk", "Orthopedic Surgery", "9 Newark Ave Ste 210", "21921"), + ("Elk River", None, "231 W Pulaski Hwy Ste 100", "21921"), + ], + "Salem": [ + ("Fenwick", "Obstetrics & Gynecology", "18 Grant St Ste 2", "08079"), + ("Mannington", "Neurology", "119 Broadway Fl 2", "08079"), + ("Yorke Street", None, "45 Yorke St Ste 1", "08079"), + ], + "West Chester": [ + ("Chester Valley", "Cardiovascular Disease", "915 Paoli Pike Ste 12", "19380"), + ("Goose Creek", "Neurology", "1315 W Chester Pike Ste 300", "19382"), + ("Marshall Square", "Internal Medicine", "16 N High St Ste 2", "19380"), + ], + "Media": [ + ("Rose Tree", "Orthopedic Surgery", "1098 W Baltimore Pike Ste 3100", "19063"), + ("Ridley Creek", "Psychiatry", "200 E State St Ste 205", "19063"), + ("Providence Road", None, "600 N Providence Rd Ste 101", "19063"), + ], + "Baltimore": [ + ("Patapsco", "Family Medicine", "3455 Wilkens Ave Ste 200", "21224"), + ("Federal Hill", "Dermatology", "1100 Light St Ste 4", "21201"), + ("Charles Village", None, "3100 St Paul St Ste 2A", "21218"), + ], +} +SECONDARY_OFFICE_TAGS = ["North Office", "Medical Arts Building", "Outpatient Center", "Professional Plaza", "Annex", "Satellite Office", "Pavilion", "Wellness Center"] +SECONDARY_STREETS = ["Concord Pike", "Kirkwood Hwy", "Limestone Rd", "Marsh Rd", "Naamans Rd", "Elkton Rd", "Pulaski Hwy", "Lancaster Pike", "Baltimore Pike", "Paoli Pike", "Salem Quinton Rd", "Route 40", "Silverside Rd", "Chestnut Hill Rd", "Old Baltimore Pike", "Eastern Ave", "Falls Rd", "Harford Rd"] +STREET_TYPES = ["Ste 100", "Ste 210", "Ste 305", "Bldg B", "Fl 2", "Ste 12", "Ste 400"] +HOURS_PATTERNS = [ + # (mon-fri open, close, sat open, sat close, sun open, sun close) + ("8:00 am", "5:00 pm", None, None, None, None), + ("8:30 am", "4:30 pm", None, None, None, None), + ("9:00 am", "5:00 pm", "9:00 am", "12:00 pm", None, None), + ("7:30 am", "4:00 pm", "8:00 am", "1:00 pm", None, None), + ("8:00 am", "6:00 pm", "9:00 am", "2:00 pm", None, None), + ("9:00 am", "4:00 pm", None, None, None, None), + ("8:00 am", "5:30 pm", "10:00 am", "2:00 pm", None, None), + ("7:00 am", "3:30 pm", "8:00 am", "11:30 am", None, None), +] +MEDICAL_SCHOOLS = [ + "Brandywine College of Medicine", "Chesapeake Bay School of Medicine", "Delaware Valley Medical College", + "Susquehanna University School of Medicine", "Allegheny Ridge College of Osteopathic Medicine", + "Harbor Point School of Medicine", "Piedmont Atlantic Medical School", "Lenape Valley College of Medicine", + "Great Falls University School of Medicine", "Tidewater College of Osteopathic Medicine", + "Monocacy School of Medicine", "Schuylkill Medical College", "Cumberland Gap University College of Medicine", + "Blue Ridge Osteopathic College", "Shenandoah College of Medicine", "Pocono Highlands Medical School", + "Cape Henlopen University School of Medicine", "Patuxent River College of Medicine", "Severn Medical College", + "Ohio Valley Osteopathic Institute", "Wyoming Valley School of Medicine", "Juniata College of Medicine", + "Rappahannock University School of Medicine", "Kittatinny Medical College", "Conestoga School of Osteopathic Medicine", + "Mid-Atlantic Institute of Medicine", "Nanticoke College of Medicine", "Choptank University School of Medicine", + "Laurel Highlands Medical College", "Tuckahoe College of Osteopathic Medicine", +] +TRAINING_HOSPITALS = [ + "Brandywine Valley Hospital", "Patapsco Harbor Medical Center", "Chester Valley Medical Center", + "Allegheny Ridge Medical Center", "Harbor Point University Hospital", "Susquehanna General Hospital", + "Lenape Valley Medical Center", "Great Falls University Hospital", "Tidewater Regional Medical Center", + "Monocacy General Hospital", "Schuylkill Medical Center", "Piedmont Atlantic Hospital", + "Cape Henlopen Medical Center", "Severn River Hospital", "Blue Ridge Regional Medical Center", + "Christina Creek Medical Center", "Riverfront General Hospital", "Shenandoah Memorial Hospital", + "Wyoming Valley Medical Center", "Kittatinny Regional Hospital", "Conestoga General Hospital", + "Nanticoke Memorial Medical Center", "Laurel Highlands Hospital", "Rappahannock University Hospital", +] +LANGUAGES = ["Spanish", "Mandarin", "Hindi", "French", "Portuguese", "Arabic", "Korean", "Russian", "Tagalog", "Vietnamese", "Polish", "Greek", "Italian", "Urdu", "Haitian Creole"] +WAIT_BUCKETS = ["Under 5 minutes", "5-15 minutes", "15-30 minutes", "Over 30 minutes"] + +MALE_FIRST = ["Aaron", "Adrian", "Alan", "Andre", "Anthony", "Arjun", "Benjamin", "Brandon", "Caleb", "Carlos", "Charles", "Christopher", "Colin", "Damian", "Daniel", "Darius", "Dean", "Derek", "Dmitri", "Douglas", "Elijah", "Emmanuel", "Eric", "Ethan", "Evan", "Felix", "Gabriel", "Gregory", "Harold", "Hector", "Henry", "Ian", "Isaac", "Jamal", "Jared", "Jerome", "Joel", "Jonah", "Joseph", "Julian", "Keith", "Kenji", "Kevin", "Leon", "Luis", "Malik", "Marcus", "Martin", "Mateo", "Matthew", "Miguel", "Nathan", "Neil", "Nikhil", "Oliver", "Omar", "Patrick", "Peter", "Rafael", "Raymond", "Ricardo", "Robert", "Roland", "Russell", "Samir", "Sean", "Simon", "Stephen", "Tariq", "Theodore", "Timothy", "Tobias", "Victor", "Vincent", "Warren", "Wesley", "Xavier", "Zachary"] +FEMALE_FIRST = ["Abigail", "Adriana", "Aisha", "Alexandra", "Alicia", "Amara", "Amelia", "Ana", "Angela", "Anita", "Beatriz", "Bianca", "Bridget", "Camila", "Carmen", "Caroline", "Catherine", "Celeste", "Claire", "Dana", "Deborah", "Denise", "Diana", "Elena", "Eleanor", "Emily", "Erin", "Esther", "Fatima", "Fiona", "Gabriela", "Grace", "Hannah", "Helen", "Ingrid", "Irene", "Isabel", "Jasmine", "Jennifer", "Joanna", "Julia", "Karen", "Kavya", "Laila", "Laura", "Leah", "Lillian", "Linda", "Lucia", "Madeline", "Margaret", "Maria", "Marisol", "Mei", "Melissa", "Miriam", "Monica", "Naomi", "Natalie", "Nicole", "Nora", "Olivia", "Patricia", "Priya", "Rachel", "Rebecca", "Renee", "Rosa", "Ruth", "Sabrina", "Samantha", "Sarah", "Simone", "Sofia", "Stephanie", "Tamara", "Teresa", "Valerie", "Vanessa", "Veronica", "Yasmin"] +NEUTRAL_FIRST = ["Alex", "Avery", "Blake", "Cameron", "Casey", "Dakota", "Drew", "Elliot", "Emerson", "Finley", "Harper", "Hayden", "Jesse", "Jordan", "Kai", "Lane", "Morgan", "Parker", "Quinn", "Reese", "Riley", "Rowan", "Sage", "Skyler", "Taylor"] +SURNAMES = ["Abernathy", "Achebe", "Adler", "Aguilar", "Ahmadi", "Alvarado", "Anand", "Archer", "Ashworth", "Baptiste", "Barlow", "Bassett", "Beckett", "Bellamy", "Benitez", "Bergstrom", "Blackwood", "Bouchard", "Brennan", "Calloway", "Carvalho", "Castellano", "Chandra", "Choudhury", "Cisneros", "Clemente", "Coleman", "Conway", "Cordova", "Crawford", "Dalton", "Danforth", "DeLuca", "Desai", "Devereaux", "Dimitriou", "Donnelly", "Draper", "Dubois", "Ellison", "Emerson", "Escobar", "Fairbanks", "Farrell", "Ferreira", "Fitzgerald", "Fontaine", "Forsythe", "Gallagher", "Galloway", "Garrison", "Gilchrist", "Goldberg", "Grantham", "Greenwood", "Guzman", "Hadley", "Halloran", "Hargrove", "Harrington", "Hastings", "Hawthorne", "Henderson", "Holloway", "Huang", "Ibarra", "Ingram", "Iyer", "Jacobsen", "Jankowski", "Jimenez", "Kaminski", "Kapoor", "Kearney", "Keller", "Kennedy", "Kimura", "Kirkland", "Kowalski", "Lachance", "Landry", "Larkin", "Lindqvist", "Lockhart", "Lombardi", "Macallister", "Maddox", "Mahoney", "Marchetti", "Matsuda", "McAllister", "Mendoza", "Merriweather", "Molina", "Montgomery", "Moreau", "Nakamura", "Navarro", "Nguyen", "Nordstrom", "Novak", "Nwachukwu", "Oduya", "Okafor", "Oliveira", "Olsen", "Ortega", "Osei", "Padilla", "Pappas", "Patel", "Pemberton", "Pereira", "Petrov", "Prescott", "Quintero", "Radcliffe", "Ramsey", "Rashid", "Redmond", "Reyes", "Rocha", "Rutherford", "Saito", "Salazar", "Sandoval", "Sattler", "Schaefer", "Sinclair", "Solano", "Soriano", "Stanton", "Sterling", "Sullivan", "Tanaka", "Thackeray", "Thornton", "Tolliver", "Trask", "Underwood", "Valdez", "Vance", "Varga", "Velasquez", "Villanueva", "Wakefield", "Waller", "Warrick", "Weatherly", "Whitaker", "Whitfield", "Winslow", "Wolcott", "Yamamoto", "Yilmaz", "Zamora", "Zhang", "Abbott", "Acosta", "Ainsley", "Alcott", "Amundsen", "Baird", "Banerjee", "Barrera", "Bishop", "Boland", "Bradshaw", "Burgess", "Cahill", "Camacho", "Carrington", "Chaudhry", "Cheng", "Costa", "Cunningham", "Dawson", "Delgado", "Doyle", "Duarte", "Eldridge", "Faulkner", "Fischer", "Flores", "Gaines", "Garza", "Gomes", "Haddad", "Hakim", "Hansen", "Hoffman", "Holt", "Hutchinson", "Jensen", "Kaur", "Khoury", "Lawson", "Lindsey", "Lowery", "Marlowe", "Mbeki", "Meyer", "Nash", "Nielsen", "Ochoa", "Pace", "Peralta", "Quigley", "Ramirez", "Rowe", "Sato", "Serrano", "Shah", "Tahir", "Torres", "Ueda", "Vasquez", "Walsh", "Wexler", "Yoon", "Zielinski", "Ashby", "Brandt", "Corwin", "Dunmore", "Ellery", "Farrow", "Gaskell", "Hollis", "Ivanova", "Joubert", "Kessler", "Lattimer"] +# Surname collision pairs: (specialty, city A, city B). The B doctor takes A's surname. +SURNAME_COLLISIONS = [ + ("Dermatology", "Newark", "Newark"), + ("Dermatology", "Wilmington", "Elkton"), + ("Cardiovascular Disease", "Wilmington", "Newark"), + ("Neurology", "West Chester", "Newark"), + ("Psychiatry", "Media", "Wilmington"), + ("Orthopedic Surgery", "Elkton", "Media"), +] + +OVERVIEW_TEMPLATES = [ + "{full} is a {specialty_lower} physician practicing at {practice} in {city}, {state}. With {years} years of experience, {pronoun} sees patients at the {city} office throughout the week.", + "{full} practices {specialty} at {practice}, based in {city}, {state}. {Pronoun} has {years} years of experience caring for patients in the {city} area.", + "Practicing {specialty} for {years} years, {full} is part of the team at {practice} in {city}, {state}, where {pronoun} welcomes patients from across the region.", + "{full} is a {specialty} specialist with {practice} in {city}, {state}. {Pronoun} brings {years} years of clinical experience to every visit.", + "Based at {practice} in {city}, {state}, {full} has practiced {specialty} for {years} years and is known in {city} for an unhurried, patient-first approach.", + "{full} joined {practice} in {city}, {state}, and has {years} years of experience in {specialty}. Patients describe the {city} office as calm and well organized.", + "With {years} years in {specialty}, {full} cares for patients at {practice} in {city}, {state}, combining thorough evaluations with clear, practical guidance.", + "{full} is a {specialty_lower} physician at {practice} in {city}, {state}, with {years} years of experience. {Pronoun} focuses on building long-term relationships with the patients {pronoun} serves.", +] +BIO_PHILOSOPHY = [ + "{Short} believes the best care starts with listening. Every visit begins with time to understand what brought the patient in, what has already been tried, and what a good outcome would look like for them.", + "{Short} takes an evidence-based, conservative approach: the least invasive option that will work is always considered first, and every treatment plan is written down so patients leave knowing exactly what happens next.", + "Patients of {short} can expect a plain-language explanation of every finding. {Pronoun_cap} keeps visits unhurried and encourages families to take part in decisions.", + "{Short} coordinates closely with each patient's other physicians so that treatment fits into the bigger picture of their health, and follows up after every significant change in care.", +] +BIO_CLOSING = [ + "{Short} is {accepting} and offers {visit_style}. Same-week appointments are usually available at the {city} office.", + "The {city} office of {practice} offers {visit_style}, and {short} is {accepting}.", + "{Short} is {accepting}. The practice offers {visit_style} and evening scheduling on request.", +] +REVIEW_TEMPLATES = { + 5: [ + "{doctor} took the time to explain my {topic} in plain language and answered every question. Wait was {wait} and the staff at {practice} were {staff}.", + "Best {visit} I have had in years. {doctor} listened carefully, laid out the options for my {topic}, and never rushed me. The {city} office runs on time.", + "I was nervous going in, but {doctor} was calm, thorough and kind. My {topic} is finally under control after {n} months of trying elsewhere.", + "{doctor} is exactly the kind of physician you hope to find: {staff} staff, {wait} wait, and a clear plan for my {topic} before I left the room.", + "Highly recommend {doctor}. The {practice} team was {staff}, the check-in was easy, and I felt genuinely heard about my {topic}.", + "Five stars for {doctor}. Follow-up call came the next day as promised, and the instructions for managing my {topic} were easy to follow.", + "After {n} years with the same {topic}, {doctor} found an approach that actually works. Wait time was {wait}. Could not be happier.", + "{doctor} explained the results of every test and made sure I understood the treatment for my {topic}. The {city} office is clean and well run.", + "Wonderful experience at {practice}. {doctor} is knowledgeable, patient and {staff}; my {topic} appointment felt thorough rather than rushed.", + "From scheduling to the {visit} itself, everything was smooth. {doctor} gave me a written plan for my {topic} and the staff were {staff}.", + ], + 4: [ + "{doctor} was knowledgeable and thorough about my {topic}. The only downside was a {wait} wait past my appointment time.", + "Good {visit} overall. {doctor} answered my questions about {topic} and the staff at {practice} were {staff}. Parking at the {city} office is tight.", + "Solid, careful physician. {doctor} recommended a sensible plan for my {topic}; I just wish the follow-up call had come sooner than {n} days later.", + "I like {doctor}: direct, {staff} and clearly experienced with {topic}. Scheduling the next visit took a couple of phone calls.", + "Very good care for my {topic}. {doctor} explained things well. The waiting room at {practice} was busy and the wait was {wait}.", + "{doctor} took my concerns about {topic} seriously and ordered the right tests. Four stars only because the office phone line is hard to reach.", + "Professional and reassuring. {doctor} walked me through my {topic} treatment step by step. Check-in at the {city} office was a little slow.", + "Happy with {doctor} after {n} visits for my {topic}. Staff are {staff}; the portal for results could be easier to use.", + ], + 3: [ + "{doctor} seems competent and the plan for my {topic} was reasonable, but the visit felt rushed and I waited {wait} to be seen.", + "Mixed experience at {practice}. {doctor} was fine, though I had to ask twice before my questions about {topic} were answered.", + "Average {visit}. {doctor} addressed my {topic} but did not explain the medication side effects until I asked. Staff were {staff}.", + "The care for my {topic} was adequate. Getting a follow-up appointment with {doctor} at the {city} office took {n} weeks.", + "{doctor} was polite and the exam was thorough, but the front desk at {practice} lost my paperwork and the wait was {wait}.", + "Okay overall. {doctor} knows {topic} well; communication between visits could be much better.", + ], + 2: [ + "Disappointed. {doctor} spent under ten minutes with me and my questions about {topic} went mostly unanswered. Wait was {wait}.", + "The staff at {practice} were {staff}, but {doctor} dismissed my concerns about {topic} and I left without a clear plan.", + "Two stars. {doctor} may be a fine physician but the {city} office is disorganized: {n} calls to get my {topic} results.", + "I did not feel listened to. {doctor} interrupted several times while I described my {topic}, and the follow-up never came.", + "Long wait ({wait}) and a hurried {visit}. {doctor} did not explain the next steps for my {topic}.", + ], + 1: [ + "Would not return. {doctor} was dismissive about my {topic} and the office at {practice} never returned {n} phone calls.", + "Terrible experience. Waited {wait} past my appointment, then {doctor} spent five minutes on my {topic} and left.", + "One star. My {topic} got worse under the plan {doctor} gave me, and nobody at the {city} office would schedule a follow-up.", + "Rude front desk, {wait} wait, and {doctor} did not review my chart before discussing my {topic}. Went elsewhere.", + ], +} +REVIEW_STAFF = ["friendly", "courteous", "helpful", "welcoming", "efficient", "patient", "attentive", "professional"] +REVIEW_WAIT = ["under five minutes", "about ten minutes", "close to twenty minutes", "roughly half an hour", "over forty minutes", "just a few minutes"] +REVIEW_VISIT = ["first visit", "follow-up", "annual checkup", "consultation", "second opinion", "new-patient appointment"] +REVIEW_TAILS = [" Recommended to my neighbors.", " Booked my next visit before leaving.", " My spouse now sees the same office.", " Worth the drive.", " Sharing so others know what to expect.", " Updated after my second visit.", " Still the same opinion a year later.", " Posting at my family's request."] +HOSPITAL_OVERVIEW = "{name} is a Hospital with 1 Location. Currently {name}'s {n} physicians cover {s} specialty areas of medicine." +PRACTICE_OVERVIEW = "{name} is a Group Practice with 1 Location. Currently {name}'s {n} physicians cover {s} specialty areas of medicine." + + +# --------------------------------------------------------------------------- # +# Small helpers +# --------------------------------------------------------------------------- # +def slugify(value: str) -> str: + out = [] + for char in value.lower(): + if char.isalnum(): + out.append(char) + elif char in " -&/'": + out.append("-") + slug = "".join(out) + while "--" in slug: + slug = slug.replace("--", "-") + return slug.strip("-") + + +def multiset(spec: list[tuple[object, int]]) -> list: + """Expand [(value, count), ...] into a list and shuffle it with the RNG.""" + values = [value for value, count in spec for _ in range(count)] + RNG.shuffle(values) + return values + + +def weighted_sample(population: list, weights: list[int], k: int) -> list: + """Weighted sampling without replacement (deterministic via RNG).""" + chosen = [] + pool = list(zip(population, weights)) + for _ in range(min(k, len(pool))): + total = sum(weight for _item, weight in pool) + pick = RNG.random() * total + cumulative = 0.0 + for index, (item, weight) in enumerate(pool): + cumulative += weight + if pick <= cumulative: + chosen.append(item) + pool.pop(index) + break + return chosen + + +def jitter(lat: float, lon: float) -> tuple[float, float]: + return round(lat + RNG.uniform(-0.012, 0.012), 6), round(lon + RNG.uniform(-0.015, 0.015), 6) + + +def phone(area: str, used: set[str]) -> str: + while True: + number = f"({area}) 555-{RNG.randint(100, 999):03d}{RNG.randint(0, 9)}" + if number not in used: + used.add(number) + return number + + +def random_date(start: date, end: date) -> date: + return start + timedelta(days=RNG.randint(0, (end - start).days)) + + +def apply_hours(target, pattern) -> None: + open_wd, close_wd, sat_open, sat_close, sun_open, sun_close = pattern + for key in ("mon", "tue", "wed", "thu", "fri"): + setattr(target, f"{key}_open", open_wd) + setattr(target, f"{key}_close", close_wd) + target.sat_open, target.sat_close = sat_open, sat_close + target.sun_open, target.sun_close = sun_open, sun_close + + +# --------------------------------------------------------------------------- # +# Builders +# --------------------------------------------------------------------------- # +def _build_vocabulary() -> dict: + specialties: dict[str, Specialty] = {} + for order, (name, slug, singular, plural, board, _cert, _sub, description) in enumerate(SPECIALTIES): + row = Specialty(name=name, slug=slug, singular=singular, plural=plural, description=description, board_name=board, display_order=order) + db.session.add(row) + specialties[name] = row + db.session.flush() + conditions: dict[str, list[Condition]] = {} + procedures: dict[str, list[Procedure]] = {} + areas: dict[str, list[ExpertiseArea]] = {} + for name, *_rest in SPECIALTIES: + conditions[name] = [Condition(name=c, slug=slugify(c), specialty_id=specialties[name].id) for c in CONDITIONS[name]] + procedures[name] = [Procedure(name=p, slug=slugify(p), specialty_id=specialties[name].id) for p in PROCEDURES[name]] + areas[name] = [ExpertiseArea(name=a, specialty_id=specialties[name].id) for a in EXPERTISE[name]] + db.session.add_all(conditions[name] + procedures[name] + areas[name]) + db.session.flush() + insurers: list[tuple[Insurer, int, list[InsurancePlan]]] = [] + for name, slug, weight, plan_types in INSURERS: + insurer = Insurer(name=name, slug=slug) + db.session.add(insurer) + db.session.flush() + plans = [InsurancePlan(insurer_id=insurer.id, plan_type=plan_type) for plan_type in plan_types] + db.session.add_all(plans) + insurers.append((insurer, weight, plans)) + db.session.flush() + cities: dict[str, City] = {} + for name, slug, state, state_name, state_slug, lat, lon, zips, _count, _area in CITIES: + city = City(name=name, slug=slug, state=state, state_name=state_name, state_slug=state_slug, lat=lat, lon=lon) + db.session.add(city) + db.session.flush() + db.session.add_all([CityZip(city_id=city.id, zip=zip_code) for zip_code in zips]) + cities[name] = city + db.session.flush() + return {"specialties": specialties, "conditions": conditions, "procedures": procedures, "areas": areas, "insurers": insurers, "cities": cities} + + +def _build_hospitals(cities: dict[str, City], used_phones: set[str]) -> dict[str, list[Hospital]]: + by_city: dict[str, list[Hospital]] = {} + area_by_city = {row[0]: row[9] for row in CITIES} + for name, city_name, street, zip_code, suffix, site in HOSPITALS: + area = area_by_city[city_name] + number = f"({area}) {suffix}" + used_phones.add(number) + hospital = Hospital( + name=name, + slug=slugify(name), + city_id=cities[city_name].id, + street=street, + zip=zip_code, + phone=number, + website=f"https://www.{site}.example", + overview_text="", + avg_rating=None, + ratings_count=0, + ) + db.session.add(hospital) + by_city.setdefault(city_name, []).append(hospital) + db.session.flush() + return by_city + + +def _build_practices(cities: dict[str, City], used_phones: set[str]) -> dict[str, list[tuple[Practice, str | None]]]: + by_city: dict[str, list[tuple[Practice, str | None]]] = {} + area_by_city = {row[0]: row[9] for row in CITIES} + for city_name in [row[0] for row in CITIES]: + for place, focus, street, zip_code in PRACTICES[city_name]: + name = PRACTICE_NAME_BY_FOCUS[focus].format(place=place) + slug = slugify(name) + practice = Practice( + name=name, + slug=slug, + city_id=cities[city_name].id, + street=street, + zip=zip_code, + phone=phone(area_by_city[city_name], used_phones), + website=f"https://www.{slug.replace('-', '')}.example", + overview_text="", + avg_rating=None, + ratings_count=0, + ) + apply_hours(practice, RNG.choice(HOURS_PATTERNS)) + db.session.add(practice) + by_city.setdefault(city_name, []).append((practice, focus)) + db.session.flush() + return by_city + + +def _doctor_slots() -> list[dict]: + """Deterministic list of (specialty, city, gender, tier) slots — 200 in radius + 24 Baltimore.""" + slots: list[dict] = [] + for spec_name, *_rest in SPECIALTIES: + cells = CLUSTERS[spec_name] + assert sum(cells.values()) == 20, spec_name + genders = multiset([("m", 9), ("f", 9), ("n", 2)]) + tiers = multiset([("Basic", 12), ("Enhanced", 8)]) + cursor = 0 + for city_name in IN_RADIUS_CITIES: + for _ in range(cells.get(city_name, 0)): + slots.append({"specialty": spec_name, "city": city_name, "gender": genders[cursor], "tier": tiers[cursor]}) + cursor += 1 + baltimore_genders = multiset([("m", 13), ("f", 11)]) + baltimore_tiers = multiset([("Basic", 14), ("Enhanced", 10)]) + cursor = 0 + for spec_name, *_rest in SPECIALTIES: + for _ in range(BALTIMORE_PER_SPECIALTY[spec_name]): + slots.append({"specialty": spec_name, "city": "Baltimore", "gender": baltimore_genders[cursor], "tier": baltimore_tiers[cursor]}) + cursor += 1 + assert len(slots) == 224 + return slots + + +def _assign_quotas(slots: list[dict]) -> None: + """Attach the quota-controlled attributes to each slot (in-radius multisets first).""" + in_radius = [slot for slot in slots if slot["city"] != "Baltimore"] + baltimore = [slot for slot in slots if slot["city"] == "Baltimore"] + ratings = multiset([(5.0, 24)] + [(None, 6)]) + ratings += multiset([(round(4.0 + 0.1 * i, 1), 9) for i in range(10)]) + ratings += multiset([(round(3.0 + 0.2 * i, 1), 10) for i in range(5)]) + ratings += multiset([(round(2.0 + 0.3 * i, 1), 5) for i in range(4)]) + ratings += multiset([(round(1.0 + 0.4 * i, 1), 5) for i in range(2)]) + ratings = ratings[:24] + ratings[24:30] + ratings[30:] + RNG.shuffle(ratings) + years = multiset([(RNG.randint(1, 4), 1) for _ in range(22)] + [(RNG.randint(5, 14), 1) for _ in range(50)] + + [(RNG.randint(15, 19), 1) for _ in range(36)] + [(RNG.randint(20, 24), 1) for _ in range(34)] + + [(RNG.randint(25, 29), 1) for _ in range(30)] + [(RNG.randint(30, 42), 1) for _ in range(28)]) + new_patients = multiset([(True, 150), (False, 50)]) + virtual = multiset([(True, 70), (False, 130)]) + medicare = multiset([(True, 130), (False, 70)]) + medicaid = multiset([(True, 90), (False, 110)]) + for index, slot in enumerate(in_radius): + slot.update(rating=ratings[index], years=years[index], new_patients=new_patients[index], virtual=virtual[index], medicare=medicare[index], medicaid=medicaid[index]) + b_ratings = multiset([(5.0, 3), (4.2, 3), (4.5, 3), (4.8, 3), (4.0, 2), (3.4, 3), (3.8, 3), (2.6, 2), (2.1, 1), (1.7, 1)]) + b_years = multiset([(RNG.randint(2, 40), 1) for _ in range(24)]) + b_new = multiset([(True, 18), (False, 6)]) + b_virtual = multiset([(True, 9), (False, 15)]) + b_medicare = multiset([(True, 16), (False, 8)]) + b_medicaid = multiset([(True, 11), (False, 13)]) + for index, slot in enumerate(baltimore): + slot.update(rating=b_ratings[index], years=b_years[index], new_patients=b_new[index], virtual=b_virtual[index], medicare=b_medicare[index], medicaid=b_medicaid[index]) + + +def _assign_names(slots: list[dict]) -> None: + surnames = list(SURNAMES) + RNG.shuffle(surnames) + males, females, neutrals = list(MALE_FIRST), list(FEMALE_FIRST), list(NEUTRAL_FIRST) + RNG.shuffle(males) + RNG.shuffle(females) + RNG.shuffle(neutrals) + cursors = {"m": 0, "f": 0, "n": 0} + pools = {"m": males, "f": females, "n": neutrals} + for index, slot in enumerate(slots): + pool = pools[slot["gender"]] + slot["first"] = pool[cursors[slot["gender"]] % len(pool)] + cursors[slot["gender"]] += 1 + slot["last"] = surnames[index] + # deliberate surname collisions (same specialty, different city or first name) + for spec_name, city_a, city_b in SURNAME_COLLISIONS: + a_candidates = [s for s in slots if s["specialty"] == spec_name and s["city"] == city_a] + b_candidates = [s for s in slots if s["specialty"] == spec_name and s["city"] == city_b and s is not a_candidates[0]] + doctor_a = a_candidates[0] + doctor_b = b_candidates[-1] + doctor_b["last"] = doctor_a["last"] + # make sure the same first+last never repeats + seen: set[tuple[str, str]] = set() + for slot in slots: + key = (slot["first"], slot["last"]) + while key in seen: + pool = pools[slot["gender"]] + slot["first"] = pool[cursors[slot["gender"]] % len(pool)] + cursors[slot["gender"]] += 1 + key = (slot["first"], slot["last"]) + seen.add(key) + + +def _build_doctors(vocab: dict, hospitals: dict[str, list[Hospital]], practices: dict[str, list[tuple[Practice, str | None]]], used_phones: set[str]) -> list[Doctor]: + specialties = vocab["specialties"] + cities = vocab["cities"] + slots = _doctor_slots() + _assign_quotas(slots) + _assign_names(slots) + area_by_city = {row[0]: row[9] for row in CITIES} + used_npis: set[str] = set() + used_slugs: set[str] = set() + practice_load: dict[int, int] = {} + secondary_load: dict[int, int] = {} + hospital_load: dict[int, int] = {} + doctors: list[Doctor] = [] + for slot in slots: + spec_name = slot["specialty"] + city_name = slot["city"] + specialty = specialties[spec_name] + years = slot["years"] + graduation_year = MIRROR_REFERENCE_DATE.year - years - RNG.randint(0, 1) + degree = "DO" if RNG.random() < 0.2 else "MD" + secondary = None + if RNG.random() < 0.3: + secondary = specialties[RNG.choice(SECONDARY_CHOICES[spec_name])] + hospital = None + if RNG.random() < 0.75: + hospital = min(hospitals[city_name], key=lambda h: (hospital_load.get(h.id, 0), h.id)) + hospital_load[hospital.id] = hospital_load.get(hospital.id, 0) + 1 + while True: + npi = "1" + "".join(str(RNG.randint(0, 9)) for _ in range(9)) + if npi not in used_npis: + used_npis.add(npi) + break + while True: + slug = f"{slugify(slot['first'])}-{slugify(slot['last'])}-{RNG.getrandbits(32):08x}" + if slug not in used_slugs: + used_slugs.add(slug) + break + # practice: prefer a focus-matching practice with room, else the least-loaded general one + options = practices[city_name] + focus_matches = [p for p, focus in options if focus == spec_name and practice_load.get(p.id, 0) < 10] + if focus_matches: + practice = focus_matches[0] + else: + general = [p for p, focus in options if focus is None and practice_load.get(p.id, 0) < 12] + candidates = general or [p for p, _f in options] + practice = min(candidates, key=lambda p: (practice_load.get(p.id, 0), p.id)) + practice_load[practice.id] = practice_load.get(practice.id, 0) + 1 + enhanced = slot["tier"] == "Enhanced" + doctor = Doctor( + slug=slug, + prefix="Dr.", + first_name=slot["first"], + last_name=slot["last"], + degree=degree, + gender=slot["gender"], + profile_type=slot["tier"], + primary_specialty_id=specialty.id, + secondary_specialty_id=secondary.id if secondary else None, + hospital_id=hospital.id if hospital else None, + avg_rating=slot["rating"], + ratings_count=0, + text_review_count=0, + years_experience=years, + graduation_year=graduation_year, + medical_school=RNG.choice(MEDICAL_SCHOOLS), + accepting_new_patients=slot["new_patients"], + virtual_visit=slot["virtual"], + npi=npi, + overview_text="", + bio_html=None, + avg_wait_minutes=RNG.choice([5, 10, 15, 20, 25, 30, 35, 40, 45]) if enhanced else None, + callout_label=None, + video_poster_file=f"images/posters/{slug}.png" if enhanced else None, + next_available_label=f"{RNG.choice(['Thu, Sep 10', 'Fri, Sep 11', 'Mon, Sep 14', 'Tue, Sep 15'])} @ {RNG.choice(['9:00 AM', '9:30 AM', '10:00 AM', '10:30 AM', '11:00 AM'])}" if enhanced else None, + website_url=practice.website if enhanced else None, + ) + db.session.add(doctor) + db.session.flush() + doctor._slot = slot # transient, seed-time only + doctor._practice = practice + # primary location + city = cities[city_name] + lat, lon = jitter(city.lat, city.lon) + primary = Location( + doctor_id=doctor.id, + practice_id=practice.id, + name=practice.name, + street=practice.street, + city_id=city.id, + zip=practice.zip, + lat=lat, + lon=lon, + phone=practice.phone, + is_primary=True, + medicare=slot["medicare"], + medicaid=slot["medicaid"], + new_patients=slot["new_patients"], + ) + apply_hours(primary, RNG.choice(HOURS_PATTERNS)) + db.session.add(primary) + # secondary locations (0 / 1 / 2) in another seeded city of the same state + extra = 0 + roll = RNG.random() + if roll < 0.10: + extra = 2 + elif roll < 0.40: + extra = 1 + same_state = [name for name, *_rest in CITIES if cities[name].state == city.state and name != city_name] or [city_name] + for _ in range(extra): + other_name = RNG.choice(same_state) + other_city = cities[other_name] + other_options = practices[other_name] + other_focus = [p for p, focus in other_options if focus == spec_name and secondary_load.get(p.id, 0) < 6] + other_general = [p for p, focus in other_options if focus is None and secondary_load.get(p.id, 0) < 8] + candidates = other_focus or other_general or [p for p, _f in other_options] + other_practice = min(candidates, key=lambda p: (secondary_load.get(p.id, 0), p.id)) + secondary_load[other_practice.id] = secondary_load.get(other_practice.id, 0) + 1 + o_lat, o_lon = jitter(other_city.lat, other_city.lon) + location = Location( + doctor_id=doctor.id, + practice_id=other_practice.id, + name=f"{other_practice.name} - {RNG.choice(SECONDARY_OFFICE_TAGS)}", + street=f"{RNG.randint(100, 4999)} {RNG.choice(SECONDARY_STREETS)} {RNG.choice(STREET_TYPES)}", + city_id=other_city.id, + zip=RNG.choice([z.zip for z in other_city.zips]), + lat=o_lat, + lon=o_lon, + phone=phone(area_by_city[other_name], used_phones), + is_primary=False, + medicare=RNG.random() < 0.6, + medicaid=RNG.random() < 0.4, + new_patients=RNG.random() < 0.7, + ) + apply_hours(location, RNG.choice(HOURS_PATTERNS)) + db.session.add(location) + doctors.append(doctor) + db.session.flush() + return doctors + + +def _build_doctor_children(doctors: list[Doctor], vocab: dict) -> None: + specialties = vocab["specialties"] + spec_by_id = {row.id: name for name, row in specialties.items()} + conditions = vocab["conditions"] + procedures = vocab["procedures"] + areas = vocab["areas"] + insurers = vocab["insurers"] + other_specs = {name: list(RELATED_SPECIALTIES[name]) for name, *_rest in SPECIALTIES} + tiers = ["Similar", "More Often", "More Than Most"] + used_review_texts: set[str] = set() + for doctor in doctors: + spec_name = spec_by_id[doctor.primary_specialty_id] + slot = doctor._slot + practice = doctor._practice + city_name = slot["city"] + # conditions: 2 own + 4-8 from other specialties + own = RNG.sample(conditions[spec_name], 2) + related_pool = [c for other in other_specs[spec_name] for c in conditions[other]] + extras = RNG.sample(related_pool, RNG.randint(4, min(7, len(related_pool)))) + ordered = own + extras + for position, condition in enumerate(ordered, start=1): + tier = RNG.choices(tiers, weights=[35, 35, 30])[0] + db.session.add(DoctorCondition(doctor_id=doctor.id, condition_id=condition.id, tier=tier, position=position)) + # procedures: 1-2 own + 3-5 other + own_procs = RNG.sample(procedures[spec_name], RNG.randint(1, 2)) + related_procs = [q for other in other_specs[spec_name] for q in procedures[other]] + other_procs = RNG.sample(related_procs, RNG.randint(3, min(5, len(related_procs)))) + for position, procedure in enumerate(own_procs + other_procs, start=1): + tier = RNG.choices(tiers, weights=[35, 35, 30])[0] + db.session.add(DoctorProcedure(doctor_id=doctor.id, procedure_id=procedure.id, tier=tier, position=position)) + # expertise 2-4 own areas + for position, area in enumerate(RNG.sample(areas[spec_name], RNG.randint(2, 4)), start=1): + db.session.add(DoctorExpertise(doctor_id=doctor.id, area_id=area.id, position=position)) + # insurers 4-9 (weighted), base plan always + 40 % of the extra plans + count = RNG.randint(4, 9) + chosen = weighted_sample(insurers, [weight for _i, weight, _p in insurers], count) + chosen.sort(key=lambda entry: entry[0].id) + for _insurer, _weight, plans in chosen: + db.session.add(DoctorInsurance(doctor_id=doctor.id, plan_id=plans[0].id, is_verified=True)) + for plan in plans[1:]: + if RNG.random() < 0.4: + db.session.add(DoctorInsurance(doctor_id=doctor.id, plan_id=plan.id, is_verified=RNG.random() < 0.85)) + # reviews + review_count = 0 if doctor.avg_rating is None else RNG.randint(3, 8) + review_dates: set[date] = set() + reviews: list[Review] = [] + topic_pool = [c.name for c in ordered[:4]] + for _ in range(review_count): + base = doctor.avg_rating + rating = int(min(5, max(1, round(base + RNG.choice([-1, -0.5, 0, 0, 0, 0.5, 1]))))) + while True: + review_date = random_date(date(2022, 1, 4), date(2026, 8, 28)) + if review_date not in review_dates: + review_dates.add(review_date) + break + text_value = _unique_review_text(rating, doctor, practice, city_name, topic_pool, used_review_texts) + good = rating >= 4 + criteria = {f"c{i}": (1 if (good or RNG.random() < 0.5) else 0) for i in range(1, 8)} + reviews.append(Review( + doctor_id=doctor.id, + rating=rating, + text=text_value, + review_date=review_date, + helpful_count=RNG.randint(0, 12), + wait_bucket=RNG.choice(WAIT_BUCKETS), + is_featured=False, + **criteria, + )) + reviews.sort(key=lambda r: r.review_date) + if len(reviews) >= 2 and RNG.random() < 0.6: + candidates = [r for r in reviews[1:] if r.rating >= 4] + if candidates: + candidates[-1].is_featured = True + db.session.add_all(reviews) + doctor.text_review_count = review_count + doctor.ratings_count = 0 if doctor.avg_rating is None else review_count + RNG.randint(0, 120) + # patients' perspective + best_label = None + best_value = -1 + for criterion in range(1, 8): + if doctor.ratings_count == 0: + did_well, needs = 0, 0 + else: + did_well = RNG.randint(max(0, doctor.ratings_count - 40), doctor.ratings_count) + needs = RNG.randint(0, max(0, int(doctor.ratings_count * (1 - (doctor.avg_rating or 3) / 5.5)))) + db.session.add(DoctorPerspective(doctor_id=doctor.id, criterion=criterion, did_well=did_well, needs_improvement=needs)) + if did_well > best_value: + best_value, best_label = did_well, PERSPECTIVE_CRITERIA[criterion - 1] + doctor.callout_label = best_label if doctor.is_enhanced and doctor.ratings_count else None + # certifications, licenses, education, languages + spec_row = next(row for row in SPECIALTIES if row[0] == spec_name) + residency_year = min(doctor.graduation_year + RNG.randint(3, 5), MIRROR_REFERENCE_DATE.year) + cert_year = min(residency_year + RNG.randint(0, 2), MIRROR_REFERENCE_DATE.year) + db.session.add(Certification(doctor_id=doctor.id, issuer=spec_row[4], cert_type=spec_row[5], year=cert_year)) + if RNG.random() < 0.3: + db.session.add(Certification(doctor_id=doctor.id, issuer=spec_row[4], cert_type=spec_row[6], year=min(cert_year + RNG.randint(1, 6), MIRROR_REFERENCE_DATE.year))) + state_name = doctor.primary_location.city.state_name + license_type = "Doctor of Osteopathic Medicine" if doctor.degree == "DO" else "Doctor of Medicine" + db.session.add(License(doctor_id=doctor.id, license_type=license_type, state=state_name, expiry_date=random_date(date(2026, 10, 1), date(2031, 12, 31)), status="Active")) + if RNG.random() < 0.35: + other_state = RNG.choice([n for n in ("Delaware", "Maryland", "Pennsylvania", "New Jersey") if n != state_name]) + db.session.add(License(doctor_id=doctor.id, license_type=license_type, state=other_state, expiry_date=random_date(date(2026, 10, 1), date(2031, 12, 31)), status="Active")) + db.session.add(Education(doctor_id=doctor.id, kind="Medical School", institution=doctor.medical_school, year=doctor.graduation_year)) + db.session.add(Education(doctor_id=doctor.id, kind="Residency", institution=RNG.choice(TRAINING_HOSPITALS), year=residency_year)) + if RNG.random() < 0.5: + db.session.add(Education(doctor_id=doctor.id, kind="Fellowship", institution=RNG.choice(TRAINING_HOSPITALS), year=min(residency_year + RNG.randint(1, 3), MIRROR_REFERENCE_DATE.year))) + db.session.add(DoctorLanguage(doctor_id=doctor.id, language="English", position=1)) + if RNG.random() < 0.45: + db.session.add(DoctorLanguage(doctor_id=doctor.id, language=RNG.choice(LANGUAGES), position=2)) + # overview + bio (vocabulary-restricted opener) + doctor.overview_text = _overview_text(doctor, spec_name, practice, city_name) + if doctor.is_enhanced: + doctor.bio_html = _bio_html(doctor, spec_name, practice, city_name, ordered[:6], own_procs + other_procs, [a.name for a in areas[spec_name]]) + # satisfaction poll counts (display-only) + for index in range(1, 6): + yes = RNG.randint(0, max(0, doctor.ratings_count // 3)) + no = RNG.randint(0, max(0, yes // 4)) + setattr(doctor, f"poll_q{index}_yes", yes) + setattr(doctor, f"poll_q{index}_no", no) + db.session.flush() + + +def _unique_review_text(rating: int, doctor: Doctor, practice: Practice, city_name: str, topics: list[str], used: set[str]) -> str: + for attempt in range(40): + template = RNG.choice(REVIEW_TEMPLATES[rating]) + text_value = template.format( + doctor=doctor.short_name, + practice=practice.name, + city=city_name, + topic=RNG.choice(topics).lower() if topics else "condition", + wait=RNG.choice(REVIEW_WAIT), + staff=RNG.choice(REVIEW_STAFF), + visit=RNG.choice(REVIEW_VISIT), + n=RNG.randint(2, 9), + ) + if attempt >= 20: + text_value += RNG.choice(REVIEW_TAILS) + if text_value not in used: + used.add(text_value) + return text_value + raise RuntimeError("could not produce a unique review text") + + +def _overview_text(doctor: Doctor, spec_name: str, practice: Practice, city_name: str) -> str: + template = RNG.choice(OVERVIEW_TEMPLATES) + pronoun = doctor.pronoun + return template.format( + full=doctor.full_name, + specialty=spec_name, + specialty_lower=spec_name.lower(), + practice=practice.name, + city=city_name, + state=doctor.primary_location.city.state, + years=doctor.years_experience, + pronoun=pronoun, + Pronoun=pronoun.capitalize(), + ) + + +def _bio_html(doctor: Doctor, spec_name: str, practice: Practice, city_name: str, conditions, procedures, areas: list[str]) -> str: + pronoun = doctor.pronoun + focus = RNG.sample(areas, 2) + paragraphs = [ + f"

Meet {doctor.full_name}:

", + f"

{doctor.overview_text}

", + f"

{doctor.possessive.capitalize()} clinical interests include {focus[0].lower()} and {focus[1].lower()}, with a particular focus on {conditions[0].name.lower()} and {conditions[1].name.lower()}.

", + "

" + RNG.choice(BIO_PHILOSOPHY).format(Short=doctor.short_name, short=doctor.short_name, Pronoun_cap=pronoun.capitalize()) + "

", + f"

{doctor.short_name} sees patients for

", + "
    " + "".join(f"
  • {c.name}
  • " for c in conditions[:5]) + "".join(f"
  • {p.name}
  • " for p in procedures[:2]) + "
", + "

" + RNG.choice(BIO_CLOSING).format( + Short=doctor.short_name, + short=doctor.short_name, + city=city_name, + practice=practice.name, + accepting="currently accepting new patients" if doctor.accepting_new_patients else "not currently accepting new patients", + visit_style="in-person and video visits" if doctor.virtual_visit else "in-person visits", + ) + "

", + ] + return "\n".join(paragraphs) + + +def _ensure_similar_tiers() -> None: + """Every condition / procedure facet keeps at least two doctors tiered "Similar".""" + for model, key in ((DoctorCondition, "condition_id"), (DoctorProcedure, "procedure_id")): + rows = model.query.order_by(model.id).all() + by_target: dict[int, list] = {} + for row in rows: + by_target.setdefault(getattr(row, key), []).append(row) + for target_id in sorted(by_target): + linked = by_target[target_id] + similar = [row for row in linked if row.tier == "Similar"] + for row in linked: + if len(similar) >= 2: + break + if row.tier != "Similar" and row.position > 5: + row.tier = "Similar" + similar.append(row) + for row in linked: + if len(similar) >= 2: + break + if row.tier != "Similar": + row.tier = "Similar" + similar.append(row) + db.session.flush() + + +def _build_awards(doctors: list[Doctor], vocab: dict) -> None: + """Patient's Choice = highest-rated doctor of each (specialty, city) cell with >= 6 doctors; + Elite / Provider drawn from the remaining highly rated doctors.""" + spec_by_id = {row.id: name for name, row in vocab["specialties"].items()} + cells: dict[tuple[str, str], list[Doctor]] = {} + for doctor in doctors: + key = (spec_by_id[doctor.primary_specialty_id], doctor._slot["city"]) + cells.setdefault(key, []).append(doctor) + awarded: set[int] = set() + years = [2024, 2025, 2026] + for key in sorted(cells): + members = [d for d in cells[key] if d.avg_rating is not None and d._slot["city"] != "Baltimore"] + if len(cells[key]) < 6: + continue + best = sorted(members, key=lambda d: (-d.avg_rating, -d.ratings_count, d.id))[0] + db.session.add(Award(doctor_id=best.id, award_class="Patient", year=RNG.choice(years))) + awarded.add(best.id) + remaining = [d for d in doctors if d.id not in awarded and d.avg_rating is not None and d.avg_rating >= 4.3] + remaining.sort(key=lambda d: d.id) + elite = RNG.sample(remaining, 16) + for doctor in elite: + db.session.add(Award(doctor_id=doctor.id, award_class="Elite", year=RNG.choice(years))) + awarded.add(doctor.id) + remaining = [d for d in doctors if d.id not in awarded and d.avg_rating is not None and d.avg_rating >= 4.0] + remaining.sort(key=lambda d: d.id) + for doctor in RNG.sample(remaining, 16): + db.session.add(Award(doctor_id=doctor.id, award_class="Provider", year=RNG.choice(years))) + db.session.flush() + + +def _finish_hubs(hospital_rows: list[Hospital], practice_rows: list[Practice]) -> None: + for hospital in hospital_rows: + doctors = list(hospital.doctors) + specialties = {d.primary_specialty_id for d in doctors} + hospital.overview_text = HOSPITAL_OVERVIEW.format(name=hospital.name, n=len(doctors), s=len(specialties)) + rated = [d.avg_rating for d in doctors if d.avg_rating is not None] + if rated and RNG.random() < 0.7: + hospital.avg_rating = round(sum(rated) / len(rated), 1) + hospital.ratings_count = RNG.randint(1, 40) + for index in range(1, 6): + yes = RNG.randint(0, 25) + setattr(hospital, f"poll_q{index}_yes", yes) + setattr(hospital, f"poll_q{index}_no", RNG.randint(0, max(0, yes // 3))) + for practice in practice_rows: + doctor_ids = sorted({loc.doctor_id for loc in practice.locations}) + doctors = [db.session.get(Doctor, doctor_id) for doctor_id in doctor_ids] + specialties = {d.primary_specialty_id for d in doctors} + practice.overview_text = PRACTICE_OVERVIEW.format(name=practice.name, n=len(doctors), s=len(specialties)) + rated = [d.avg_rating for d in doctors if d.avg_rating is not None] + if rated and RNG.random() < 0.6: + practice.avg_rating = round(sum(rated) / len(rated), 1) + practice.ratings_count = RNG.randint(1, 30) + for index in range(1, 6): + yes = RNG.randint(0, 15) + setattr(practice, f"poll_q{index}_yes", yes) + setattr(practice, f"poll_q{index}_no", RNG.randint(0, max(0, yes // 3))) + db.session.flush() + + +# --------------------------------------------------------------------------- # +# Seed entry points (whole-function gates) +# --------------------------------------------------------------------------- # +def seed_database(force: bool = False) -> None: + if Doctor.query.count() > 0 and not force: + return + RNG.seed(SEED) + used_phones: set[str] = set() + vocab = _build_vocabulary() + hospitals = _build_hospitals(vocab["cities"], used_phones) + practices = _build_practices(vocab["cities"], used_phones) + doctors = _build_doctors(vocab, hospitals, practices, used_phones) + _build_doctor_children(doctors, vocab) + _ensure_similar_tiers() + _build_awards(doctors, vocab) + _finish_hubs(Hospital.query.order_by(Hospital.id).all(), Practice.query.order_by(Practice.id).all()) + for doctor in doctors: + del doctor._slot + del doctor._practice + + +def seed_benchmark_users(force: bool = False) -> None: + if User.query.count() > 0 and not force: + return + users: dict[str, User] = {} + for email, display, dob in BENCHMARK_USERS: + user = User(email=email, password_hash=DEMO_PASSWORD_HASHES[email], dob=dob, display_name=display, created_at=USER_CREATED_AT) + db.session.add(user) + users[email] = user + db.session.flush() + specialties = {row.name: row for row in Specialty.query.all()} + + def nth_doctor(spec_name: str, n: int, **filters) -> Doctor: + rows = Doctor.query.filter_by(primary_specialty_id=specialties[spec_name].id, **filters).order_by(Doctor.id).all() + return rows[n] + + alice = users["alice.j@test.com"] + # exactly one Dermatologist among Alice's three saved providers + alice_saved = [ + (nth_doctor("Dermatology", 4), datetime(2026, 7, 2, 18, 5, 0)), + (nth_doctor("Family Medicine", 6), datetime(2026, 7, 19, 8, 41, 0)), + (nth_doctor("Neurology", 3), datetime(2026, 8, 6, 12, 27, 0)), + ] + for doctor, saved_at in alice_saved: + db.session.add(SavedProvider(user_id=alice.id, doctor_id=doctor.id, saved_at=saved_at)) + bob = users["bob.c@test.com"] + db.session.add(SavedProvider(user_id=bob.id, doctor_id=nth_doctor("Cardiovascular Disease", 2).id, saved_at=datetime(2026, 8, 11, 16, 48, 0))) + enhanced = nth_doctor("Gastroenterology", 1, profile_type="Enhanced") + booking = AppointmentRequest( + user_id=alice.id, + doctor_id=enhanced.id, + location_id=enhanced.primary_location.id, + patient_type="Returning Patient", + slot_date=date(2026, 9, 11), + slot_time="9:30 AM", + reference="pending", + created_at=datetime(2026, 8, 20, 9, 12, 0), + ) + db.session.add(booking) + db.session.flush() + booking.reference = confirmation_reference(booking.id) + reviewed = nth_doctor("Family Medicine", 6) + db.session.add(UserReview( + user_id=alice.id, + doctor_id=reviewed.id, + rating=4, + c1=1, c2=1, c3=0, c4=1, c5=1, c6=1, c7=0, + text="Thorough annual visit and clear answers about my lab results; scheduling the follow-up took two calls.", + status="Pending review", + created_at=datetime(2026, 8, 22, 14, 3, 0), + )) + db.session.flush() + + +# --------------------------------------------------------------------------- # +# Build-time invariant checks (gitignored scripts_dev/assert_distractors.py) +# --------------------------------------------------------------------------- # +def _load_distractor_checks(): + path = BASE_DIR / "scripts_dev" / "assert_distractors.py" + if not path.exists(): + return None + spec = importlib.util.spec_from_file_location("webmd_doctor_assert_distractors", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.assert_distractors + + +def _current_counts() -> dict[str, int]: + models = { + "specialties": Specialty, "conditions": Condition, "procedures": Procedure, "expertise_areas": ExpertiseArea, + "insurers": Insurer, "insurance_plans": InsurancePlan, "cities": City, "city_zips": CityZip, + "hospitals": Hospital, "practices": Practice, "doctors": Doctor, "locations": Location, + "doctor_conditions": DoctorCondition, "doctor_procedures": DoctorProcedure, "doctor_expertise": DoctorExpertise, + "doctor_insurances": DoctorInsurance, "reviews": Review, "doctor_perspectives": DoctorPerspective, + "certifications": Certification, "licenses": License, "education": Education, "awards": Award, + "doctor_languages": DoctorLanguage, "users": User, "saved_providers": SavedProvider, + "appointment_requests": AppointmentRequest, "user_reviews": UserReview, + } + return {key: model.query.count() for key, model in models.items()} + + +def _seed_is_complete() -> bool: + marker = db.session.get(SeedMetadata, "version") + counts = _current_counts() + core_match = all(counts[key] == EXPECTED_COUNTS[key] for key in CORE_COUNT_KEYS) + benchmark_emails = {email for email, _d, _b in BENCHMARK_USERS} + present = {row.email for row in User.query.filter(User.email.in_(benchmark_emails)).all()} + return marker is not None and marker.value == SEED_VERSION and core_match and present == benchmark_emails + + +def _database_has_seed_rows() -> bool: + return any(_current_counts().values()) or SeedMetadata.query.count() > 0 + + +def _validate_seed() -> None: + counts = _current_counts() + if counts != EXPECTED_COUNTS: + raise RuntimeError(f"seed row counts differ: expected={EXPECTED_COUNTS}, actual={counts}") + violations = db.session.execute(text("PRAGMA foreign_key_check")).all() + if violations: + raise RuntimeError(f"seed foreign-key violations: {violations[:5]}") + + +def ensure_seed_database() -> None: + if _seed_is_complete(): + return + if _database_has_seed_rows(): + raise RuntimeError("webmd_doctor database is partial, unversioned, or from another seed version") + try: + seed_database(force=True) + seed_benchmark_users(force=True) + _validate_seed() + db.session.add(SeedMetadata(key="version", value=SEED_VERSION)) + db.session.commit() + except Exception: + db.session.rollback() + raise + + +# --------------------------------------------------------------------------- # +# Generated images (deterministic PNG bytes, no text chunks) +# --------------------------------------------------------------------------- # +AVATAR_PALETTE = [ + (0, 21, 124), (53, 87, 255), (14, 116, 144), (99, 64, 178), (27, 94, 32), (150, 63, 122), + (180, 83, 9), (0, 105, 92), (71, 85, 105), (120, 40, 40), (34, 64, 140), (88, 110, 40), +] + + +def _initials_font(size: int): + from PIL import ImageFont + + return ImageFont.load_default(size=size) + + +def write_images(doctors: list[Doctor]) -> None: + from PIL import Image, ImageDraw + + AVATAR_DIR.mkdir(parents=True, exist_ok=True) + POSTER_DIR.mkdir(parents=True, exist_ok=True) + for stale in sorted(AVATAR_DIR.glob("*.png")) + sorted(POSTER_DIR.glob("*.png")): + stale.unlink() + font_large = _initials_font(58) + font_poster = _initials_font(72) + for doctor in sorted(doctors, key=lambda d: d.id): + initials = (doctor.first_name[:1] + doctor.last_name[:1]).upper() + colour = AVATAR_PALETTE[doctor.id % len(AVATAR_PALETTE)] + image = Image.new("RGB", (150, 150), (241, 246, 250)) + draw = ImageDraw.Draw(image) + draw.ellipse((0, 0, 149, 149), fill=colour) + box = draw.textbbox((0, 0), initials, font=font_large) + width, height = box[2] - box[0], box[3] - box[1] + draw.text(((150 - width) / 2 - box[0], (150 - height) / 2 - box[1]), initials, fill=(255, 255, 255), font=font_large) + image.save(AVATAR_DIR / f"{doctor.slug}.png", format="PNG", optimize=True) + if doctor.is_enhanced: + poster = Image.new("RGB", (640, 360), (0, 6, 37)) + pdraw = ImageDraw.Draw(poster) + for y in range(360): + shade = int(6 + (y / 360) * 40) + pdraw.line((0, y, 639, y), fill=(0, shade, 37 + shade)) + pdraw.ellipse((245, 105, 395, 255), fill=colour) + box = pdraw.textbbox((0, 0), initials, font=font_poster) + width, height = box[2] - box[0], box[3] - box[1] + pdraw.text((320 - width / 2 - box[0], 180 - height / 2 - box[1]), initials, fill=(255, 255, 255), font=font_poster) + pdraw.ellipse((560, 280, 620, 340), fill=(53, 87, 255)) + pdraw.polygon([(582, 296), (582, 324), (606, 310)], fill=(255, 255, 255)) + poster.save(POSTER_DIR / f"{doctor.slug}.png", format="PNG", optimize=True) + + +def build_seed_database() -> None: + INSTANCE_SEED_DIR.mkdir(parents=True, exist_ok=True) + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + destination = INSTANCE_SEED_DIR / "webmd_doctor.db" + checks = _load_distractor_checks() + try: + with app.app_context(): + db.session.remove() + db.engine.dispose() + if DB_PATH.exists(): + DB_PATH.unlink() + with app.app_context(): + db.create_all() + ensure_seed_database() + if checks is not None: + checks() + write_images(Doctor.query.order_by(Doctor.id).all()) + db.session.remove() + with db.engine.connect() as connection: + connection.execute(text("VACUUM")) + db.engine.dispose() + temporary = destination.with_suffix(".db.tmp") + shutil.copyfile(DB_PATH, temporary) + os.replace(temporary, destination) + except Exception: + destination.with_suffix(".db.tmp").unlink(missing_ok=True) + DB_PATH.unlink(missing_ok=True) + raise + if checks is None: + print("scripts_dev/assert_distractors.py not present - skipping the build-time task invariants.") + + +if __name__ == "__main__": + build_seed_database() + print("Seed database, avatars and posters generated for WebMD Doctor.") diff --git a/sites/webmd_doctor/static/css/site.css b/sites/webmd_doctor/static/css/site.css new file mode 100644 index 00000000..0c32c4b2 --- /dev/null +++ b/sites/webmd_doctor/static/css/site.css @@ -0,0 +1,502 @@ +/* WebMD Care mirror — chrome styles (colours / type from recon §5) */ +@font-face { font-family: "Source Sans 3"; font-style: normal; font-weight: 400; font-display: swap; src: url("../fonts/source-sans-3-400.woff2") format("woff2"); } +@font-face { font-family: "Source Sans 3"; font-style: normal; font-weight: 600; font-display: swap; src: url("../fonts/source-sans-3-600.woff2") format("woff2"); } +@font-face { font-family: "Source Sans 3"; font-style: normal; font-weight: 700; font-display: swap; src: url("../fonts/source-sans-3-700.woff2") format("woff2"); } + +:root { + --navy: #000625; --card-navy: #00157c; --blue: #3557ff; --blue-dark: #1f3ed6; --red: #e03a3e; + --text: #1b1b1b; --text-2: #303133; --text-3: #4a4a4a; --muted: #909399; --border: #e4e7ed; --border-2: #d4d4d4; + --bg: #f1f6fa; --bg-2: #eef1ff; --gold: #f5b400; --green: #2e9e5b; --white: #fff; + --font: "Source Sans 3", "Source Sans Pro", Calibri, Corbel, "Segoe UI", Helvetica, Arial, sans-serif; +} +* { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; } +body { margin: 0; font-family: var(--font); font-size: 16px; line-height: 1.4; color: var(--text); background: var(--bg); } +a { color: var(--blue); text-decoration: none; } +a:hover { text-decoration: underline; } +h1, h2, h3, h4 { margin: 0; font-weight: 700; line-height: 1.15; } +p { margin: 0 0 12px; } +ul { margin: 0; padding: 0; list-style: none; } +img { max-width: 100%; } +button, input, select, textarea { font: inherit; } +.container { width: 100%; max-width: 1280px; margin: 0 auto; padding: 0 16px; } +.wrap { max-width: 1280px; margin: 0 auto; padding: 0 16px; } +.ic { width: 16px; height: 16px; vertical-align: -3px; fill: currentColor; flex: none; } +.ic-lg { width: 22px; height: 22px; } +.ic-blue { color: var(--blue); } +.muted { color: var(--muted); } +.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } +.hidden { display: none !important; } + +/* ---------- buttons ---------- */ +.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 10px 20px; border-radius: 3px; border: 1px solid transparent; font-weight: 600; cursor: pointer; text-decoration: none; line-height: 1.2; } +.btn:hover { text-decoration: none; } +.btn-primary { background: var(--blue); color: #fff; border-color: var(--blue); } +.btn-primary:hover { background: var(--blue-dark); } +.btn-red { background: var(--red); color: #fff; border-color: var(--red); border-radius: 22px; } +.btn-ghost { background: #fff; color: var(--blue); border-color: var(--blue); } +.btn-ghost:hover { background: var(--bg-2); } +.btn-outline { background: #fff; color: var(--text); border-color: var(--border-2); } +.btn-pill { border-radius: 9999px; } +.btn-wide { width: 100%; } +.btn-sm { padding: 6px 14px; font-size: 14px; } +.btn-link { background: none; border: 0; padding: 0; color: var(--blue); cursor: pointer; font-weight: 400; } +.btn-link:hover { text-decoration: underline; } +.btn[disabled] { opacity: .5; cursor: default; } + +/* ---------- header ---------- */ +.site-header { background: var(--navy); color: #fff; } +.header-bar { display: flex; align-items: center; justify-content: space-between; height: 52px; gap: 16px; } +.brand { display: flex; align-items: center; color: #fff; text-decoration: none; } +.brand:hover { text-decoration: none; } +.brand svg { height: 26px; width: auto; display: block; } +.header-nav { display: flex; align-items: center; gap: 28px; font-size: 16px; font-weight: 600; } +.header-nav > li { position: relative; } +.header-nav a, .header-nav .nav-btn { color: #fff; display: inline-flex; align-items: center; gap: 6px; background: none; border: 0; padding: 14px 0; cursor: pointer; font-weight: 600; } +.header-nav a:hover { text-decoration: none; opacity: .9; } +.menu { position: absolute; top: 100%; right: 0; z-index: 40; background: #fff; color: var(--text); min-width: 260px; border: 1px solid var(--border); border-radius: 4px; box-shadow: 0 8px 24px rgba(0,0,0,.18); padding: 10px 0; display: none; } +.menu.open { display: block; } +.menu.menu-wide { min-width: 560px; column-count: 2; column-gap: 0; } +.menu a, .menu button { display: block; width: 100%; text-align: left; padding: 8px 20px; color: var(--text-2); font-weight: 400; background: none; border: 0; cursor: pointer; break-inside: avoid; } +.menu a:hover, .menu button:hover { background: var(--bg-2); color: var(--blue); text-decoration: none; } +.menu .menu-title { padding: 6px 20px 8px; font-weight: 700; color: var(--card-navy); column-span: all; border-bottom: 1px solid var(--border); margin-bottom: 6px; } +.menu-account { min-width: 220px; } +.search-row { padding: 0 0 14px; } +.search-bar { display: flex; background: #fff; border-radius: 4px; overflow: hidden; height: 52px; box-shadow: 0 1px 3px rgba(0,0,0,.25); } +.search-bar .field { display: flex; align-items: center; gap: 10px; flex: 1; padding: 0 16px; border-right: 1px solid var(--border); position: relative; } +.search-bar .field:last-of-type { border-right: 0; } +.search-bar input { border: 0; outline: 0; width: 100%; height: 100%; font-size: 17px; color: var(--text); background: transparent; } +.search-bar input::placeholder { color: #6b6b6b; } +.search-bar .ic { color: var(--blue); width: 18px; height: 18px; } +.search-bar button { border: 0; background: var(--blue); color: #fff; font-weight: 700; padding: 0 28px; display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 15px; letter-spacing: .3px; } +.search-bar button .ic { color: #fff; } +.typeahead { position: absolute; top: 100%; left: 0; right: 0; background: #fff; color: var(--text); border: 1px solid var(--border); border-top: 0; z-index: 50; display: none; max-height: 320px; overflow: auto; text-align: left; } +.typeahead.open { display: block; } +.typeahead .ta-section { padding: 8px 16px 4px; font-size: 12px; font-weight: 700; letter-spacing: 1px; color: var(--muted); } +.typeahead a { display: block; padding: 6px 16px; color: var(--text); } +.typeahead a:hover, .typeahead a.active { background: var(--bg-2); text-decoration: none; } + +/* ---------- home ---------- */ +.hero { background: var(--navy); color: #fff; text-align: center; padding: 64px 0 60px; } +.hero .kicker { font-size: 22px; font-weight: 600; margin-bottom: 6px; } +.hero h1 { font-size: 46px; font-weight: 400; margin-bottom: 36px; } +.hero .search-bar { max-width: 1000px; margin: 0 auto; } +.chips { display: flex; flex-wrap: wrap; justify-content: center; gap: 16px; margin-top: 20px; } +.chip { display: inline-block; border: 1px solid #fff; color: #fff; border-radius: 9999px; padding: 4px 16px; font-size: 16px; } +.chip:hover { background: rgba(255,255,255,.12); text-decoration: none; } +.home-promo { background: var(--bg-2); padding: 70px 0; } +.home-promo .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; align-items: center; } +.home-promo h2 { font-size: 40px; font-weight: 300; color: var(--text-2); margin-bottom: 24px; } +.home-promo li { display: flex; gap: 20px; font-size: 20px; margin: 10px 0 10px 40px; } +.home-promo li::before { content: ""; width: 14px; height: 4px; background: var(--blue); margin-top: 12px; flex: none; border-radius: 2px; } +.promo-art { position: relative; height: 380px; } +.promo-card { position: absolute; background: #fff; border-radius: 6px; box-shadow: 0 6px 24px rgba(0,0,0,.12); padding: 16px 20px; font-size: 13px; } +.promo-card.rating { left: 0; top: 30px; width: 190px; } +.promo-card.facts { right: 20px; bottom: 20px; width: 200px; } +.promo-card.facts li { margin: 0; font-size: 13px; gap: 8px; align-items: center; } +.promo-card.facts li::before { display: none; } +.promo-photo { position: absolute; left: 100px; top: 0; width: 360px; height: 360px; border-radius: 8px; background: linear-gradient(160deg, #c9d3ff, #5f7cff 70%, #00157c); } +.home-section { padding: 48px 0; background: #fff; } +.home-section h2 { font-size: 30px; font-weight: 300; color: var(--card-navy); margin-bottom: 24px; } +.spec-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; } +.spec-tile { display: block; text-align: center; padding: 22px 8px; border: 1px solid var(--border); border-radius: 6px; color: var(--text-2); font-weight: 700; font-size: 13px; letter-spacing: .8px; text-transform: uppercase; } +.spec-tile:hover { border-color: var(--blue); color: var(--blue); text-decoration: none; } +.top-docs { text-align: center; } +.top-docs .kicker { color: var(--card-navy); font-weight: 700; font-size: 18px; } +.top-docs .place { color: var(--blue); font-size: 18px; margin-bottom: 20px; } +.mini-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; } +.mini-grid.six { grid-template-columns: repeat(5, 1fr); } +.mini-grid.three { grid-template-columns: repeat(3, 1fr); max-width: 900px; margin: 0 auto; } +.mini-card { background: #fff; border: 1px solid var(--border); border-radius: 6px; padding: 22px 14px; text-align: center; } +.mini-card .avatar { width: 64px; height: 64px; border-radius: 50%; margin: 0 auto 10px; display: block; } +.mini-card .name { font-weight: 700; font-size: 16px; color: var(--text); } +.mini-card .spec { font-size: 13px; color: var(--text-3); } +.mini-card .rating-line { font-size: 12px; margin: 6px 0; justify-content: center; flex-wrap: wrap; gap: 4px; } +.mini-card .meta { font-size: 13px; color: var(--text-3); } +.mini-card .btn { margin-top: 12px; } +.awards-band { background: var(--navy); color: #fff; padding: 60px 0; } +.awards-band .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; align-items: center; } +.awards-band h3 { font-size: 18px; margin-bottom: 4px; } +.awards-band h2 { font-size: 30px; font-weight: 400; margin-bottom: 16px; } +.awards-band .art { height: 220px; border-radius: 8px; background: radial-gradient(circle at 30% 30%, #2b3f9e, var(--navy) 70%); } +.by-specialty { background: var(--bg-2); padding: 50px 0; } +.by-specialty h2 { text-align: center; font-size: 34px; font-weight: 300; color: var(--card-navy); margin-bottom: 30px; } +.by-specialty .label { font-weight: 700; color: var(--card-navy); letter-spacing: .5px; font-size: 14px; margin-bottom: 12px; } +.spec-cols { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px 20px; } +.spec-cols a { color: var(--text-2); font-size: 15px; } +.hero.compact { padding: 40px 0 44px; } +.hero.compact h1 { font-size: 30px; margin-bottom: 20px; } +.browse-popular { padding: 40px 0; background: #fff; } +.browse-popular h2 { font-size: 15px; letter-spacing: .5px; color: var(--card-navy); text-transform: uppercase; border-bottom: 2px solid var(--blue); display: inline-block; margin-bottom: 20px; } +.browse-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px 18px; } +.browse-grid a { display: flex; justify-content: space-between; align-items: center; border: 1px solid var(--border-2); padding: 10px 12px; color: var(--text); font-size: 14px; font-weight: 600; border-radius: 3px; } + +/* ---------- results ---------- */ +.page { padding: 24px 0 48px; } +.breadcrumb { font-size: 13px; color: var(--text-3); margin-bottom: 14px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } +.breadcrumb a { color: var(--blue); } +.breadcrumb .sep { color: var(--muted); } +.results-head h1 { font-size: 34px; font-weight: 400; color: var(--text); } +.results-head h1 strong { font-weight: 700; } +.results-head .count { color: var(--text-3); margin: 6px 0 16px; font-size: 16px; } +.notice { background: #fff7e0; border: 1px solid #f2d78a; border-radius: 4px; padding: 10px 14px; margin: 0 0 16px; font-size: 14px; } +.filter-bar { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 18px; } +.filter-item { position: relative; } +.pill { display: inline-flex; align-items: center; gap: 10px; height: 42px; padding: 0 16px; border: 1px solid var(--border-2); border-radius: 3px; background: #fff; color: var(--blue); font-weight: 600; cursor: pointer; font-size: 16px; } +.pill .ic { color: var(--blue); } +.pill.active { background: var(--bg-2); border-color: var(--blue); } +.pill input[type=checkbox] { width: 18px; height: 18px; accent-color: var(--blue); margin: 0; } +.pill-icon { width: 42px; justify-content: center; padding: 0; } +.popover { position: absolute; top: 100%; left: 0; margin-top: 6px; z-index: 30; background: #fff; border: 1px solid var(--border); border-radius: 4px; box-shadow: 0 8px 24px rgba(0,0,0,.16); padding: 14px 16px; min-width: 240px; display: none; } +.popover.open { display: block; } +.popover label { display: flex; align-items: center; gap: 10px; padding: 6px 0; cursor: pointer; font-size: 15px; color: var(--text-2); } +.popover input[type=radio], .popover input[type=checkbox] { accent-color: var(--blue); width: 16px; height: 16px; margin: 0; } +.popover select { width: 100%; padding: 8px; border: 1px solid var(--border-2); border-radius: 3px; } +.popover .row { display: flex; gap: 10px; align-items: center; } +.popover .apply { margin-top: 10px; display: flex; justify-content: flex-end; gap: 8px; } +.info-banner { display: flex; align-items: center; gap: 14px; background: #fff; border: 1px solid var(--border); border-radius: 4px; padding: 12px 16px; margin-bottom: 18px; font-size: 15px; } +.info-banner .badge { width: 44px; height: 44px; border-radius: 50%; background: var(--bg-2); color: var(--card-navy); display: flex; align-items: center; justify-content: center; font-size: 9px; font-weight: 700; text-align: center; line-height: 1.1; flex: none; border: 1px solid #c6cff5; } +.section-label { color: var(--text-3); font-size: 15px; margin: 8px 0 12px; } +.card-list { display: flex; flex-direction: column; gap: 16px; } + +/* physician card */ +.phys-card { background: #fff; border: 1px solid var(--border); border-radius: 4px; padding: 24px; display: flex; gap: 24px; box-shadow: 0 1px 3px rgba(0,0,0,.06); } +.phys-card .avatar-col { width: 120px; flex: none; text-align: center; } +.phys-card .avatar { width: 120px; height: 120px; border-radius: 50%; display: block; object-fit: cover; } +.phys-card .verified { display: inline-flex; align-items: center; gap: 4px; margin-top: 8px; color: var(--card-navy); font-weight: 700; font-size: 12px; letter-spacing: .5px; } +.phys-card .body { flex: 1; min-width: 0; } +.phys-card h3 { font-size: 24px; margin-bottom: 2px; } +.phys-card h3 a { color: var(--text); } +.phys-card .spec { font-size: 15px; color: var(--text-2); } +.rating-line { display: flex; align-items: center; gap: 6px; font-size: 15px; margin: 4px 0 8px; } +.rating-line .num { font-weight: 600; } +.stars { display: inline-flex; gap: 1px; } +.stars .ic { width: 17px; height: 17px; color: var(--gold); } +.stars .ic.off { color: #d8d8d8; } +.stars.small .ic { width: 13px; height: 13px; } +.facts { display: flex; flex-direction: column; gap: 3px; font-size: 14px; color: var(--text-2); } +.facts li { display: flex; align-items: center; gap: 6px; } +.facts .ic { color: var(--blue); width: 15px; height: 15px; } +.facts .ic.gray { color: #9aa0b8; } +.addr { margin: 10px 0 8px; font-size: 14px; color: var(--text-2); } +.addr .dist { margin-left: 10px; } +.snippet { font-size: 15px; color: var(--text-2); line-height: 1.35; } +.tele-pill { display: inline-block; background: var(--bg-2); color: var(--card-navy); font-size: 13px; padding: 4px 12px; border-radius: 3px; margin: 8px 0; } +.phys-card .cta-col { width: 300px; flex: none; display: flex; flex-direction: column; gap: 10px; align-items: stretch; } +.phys-card .cta-col .btn { border-radius: 22px; font-size: 16px; } +.phys-card.compact { padding: 16px 20px; } +.card-actions { margin-top: 10px; display: flex; gap: 10px; align-items: center; } + +/* pagination */ +.pagination { display: flex; justify-content: center; align-items: center; gap: 6px; margin: 28px 0 8px; } +.pagination a, .pagination span { min-width: 34px; height: 34px; display: inline-flex; align-items: center; justify-content: center; border-radius: 3px; font-weight: 600; padding: 0 8px; } +.pagination a { background: #fff; border: 1px solid var(--border-2); color: var(--text-2); } +.pagination a:hover { text-decoration: none; border-color: var(--blue); color: var(--blue); } +.pagination .current { background: var(--blue); color: #fff; } +.pagination .ellipsis { color: var(--muted); } +.pagination a.nav { background: var(--blue); color: #fff; border-color: var(--blue); } +.pagination a.nav[aria-disabled=true] { opacity: .35; pointer-events: none; } + +/* ---------- doctor profile ---------- */ +.profile-hero { background: var(--card-navy); color: #fff; border-radius: 4px; position: relative; overflow: hidden; display: grid; grid-template-columns: 1fr 400px; } +.profile-hero .hero-main { padding: 22px 28px 28px; display: grid; grid-template-columns: 170px 1fr; gap: 20px; position: relative; z-index: 1; } +.profile-hero .hero-art { position: absolute; right: 0; bottom: 0; width: 520px; height: 300px; opacity: .55; pointer-events: none; } +.profile-hero .avatar-col { text-align: center; } +.profile-hero .avatar { width: 170px; height: 170px; border-radius: 50%; display: block; border: 3px solid rgba(255,255,255,.2); } +.profile-hero .play { position: absolute; } +.profile-hero .verified { display: inline-flex; align-items: center; gap: 4px; margin-top: 8px; font-weight: 700; font-size: 14px; letter-spacing: .5px; } +.profile-hero h1 { font-size: 40px; margin-bottom: 4px; } +.profile-hero .spec { font-size: 22px; font-weight: 700; margin: 12px 0 8px; } +.profile-hero .rating-line { font-size: 16px; } +.profile-hero a { color: #fff; text-decoration: underline; } +.profile-hero .facts { font-size: 15px; color: #fff; gap: 8px; margin-top: 10px; } +.profile-hero .facts .ic { color: #fff; } +.profile-hero .facts strong { font-weight: 700; } +.accept-pill { display: inline-block; background: #fff; color: var(--card-navy); font-weight: 600; font-size: 14px; padding: 4px 10px; border-radius: 3px; margin: 8px 0; } +.hero-phone { display: inline-flex; margin: 8px 0; } +.affiliation { position: absolute; top: 18px; right: 20px; background: #fff; color: var(--text-2); border-radius: 4px; padding: 8px 14px; display: flex; align-items: center; gap: 12px; font-size: 11px; line-height: 1.2; z-index: 2; } +.affiliation strong { font-size: 13px; color: var(--card-navy); } +.affiliation a { color: var(--card-navy); text-decoration: none; } +.hero-side { background: #fff; color: var(--text); padding: 70px 24px 24px; } +.hero-side h3 { font-size: 18px; margin-bottom: 14px; } +.hero-side.rail-only { padding-top: 70px; } +.book-widget label.lbl { display: block; font-weight: 700; font-size: 14px; margin: 10px 0 6px; } +.book-widget select { width: 100%; padding: 10px; border: 1px solid var(--border-2); border-radius: 3px; } +.seg { display: flex; gap: 10px; } +.seg label { flex: 1; display: flex; align-items: center; gap: 8px; border: 1px solid var(--border-2); border-radius: 3px; padding: 8px 10px; font-size: 14px; cursor: pointer; } +.seg label.on { border-color: var(--blue); } +.month { text-align: center; font-weight: 700; margin: 12px 0 8px; color: var(--card-navy); } +.grid-days { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; } +.grid-days .day-head { text-align: center; font-size: 12px; font-weight: 700; line-height: 1.2; } +.grid-days .day-head span { display: block; font-weight: 400; } +.slot { display: block; text-align: center; font-size: 12px; padding: 6px 2px; border: 1px solid var(--border-2); border-radius: 3px; cursor: pointer; margin-top: 6px; } +.slot input { position: absolute; opacity: 0; } +.slot.on, .slot:has(input:checked) { background: var(--bg-2); border-color: var(--blue); color: var(--blue); font-weight: 700; } +.tabs { display: flex; gap: 20px; background: #fff; border: 1px solid var(--border); border-top: 0; padding: 0 20px; margin-bottom: 20px; } +.tabs a { padding: 14px 0; font-weight: 700; color: var(--text-2); border-bottom: 3px solid transparent; font-size: 14px; letter-spacing: .3px; } +.tabs a.on { color: var(--card-navy); border-color: var(--blue); } +.tabs a:hover { text-decoration: none; color: var(--blue); } +.profile-grid { display: grid; grid-template-columns: 1fr 400px; gap: 24px; align-items: start; } +.stack { display: flex; flex-direction: column; gap: 16px; min-width: 0; } +.rail { display: flex; flex-direction: column; gap: 16px; position: sticky; top: 16px; } +.panel { background: #fff; border: 1px solid var(--border); border-radius: 4px; padding: 22px 28px 26px; } +.panel-head { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 14px; } +.panel-head .ic { width: 22px; height: 22px; color: var(--blue); margin-top: 2px; } +.panel-head h2 { font-size: 17px; font-weight: 700; } +.panel-head h2::after { content: ""; display: block; width: 40px; height: 3px; background: #c9d1ff; margin-top: 8px; } +.panel-body { padding-left: 38px; font-size: 15px; color: var(--text-2); } +.panel-body p { line-height: 1.5; } +.panel-body li { line-height: 1.5; } +.bio ul { list-style: disc; padding-left: 22px; margin: 6px 0 12px; } +.bio strong { color: var(--card-navy); } +.bio-clip { max-height: 260px; overflow: hidden; position: relative; } +.bio-clip.expanded { max-height: none; } +.video-poster { position: relative; display: block; } +.video-poster img { width: 100%; border-radius: 4px; display: block; } +.quote { font-size: 15px; line-height: 1.6; } +.loc { display: grid; grid-template-columns: 1fr 200px; gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--border); } +.loc:last-child { border-bottom: 0; } +.loc .practice-link { font-weight: 700; text-decoration: underline; color: var(--text); display: inline-block; margin-bottom: 4px; } +.loc .loc-name { font-weight: 700; } +.loc .map { background: #dedede; border-radius: 3px; height: 120px; display: flex; align-items: center; justify-content: center; color: #7a7a7a; font-size: 12px; } +.hours { display: grid; grid-template-columns: 44px 1fr; gap: 2px 8px; font-size: 14px; margin-top: 10px; } +.hours dt, .hours dd { margin: 0; } +.review-summary { display: grid; grid-template-columns: 1fr auto; gap: 16px; align-items: start; } +.review-summary .big { font-size: 22px; font-weight: 700; display: flex; align-items: center; gap: 8px; } +.criteria-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 24px; margin: 10px 0 16px; } +.criterion .bar { height: 5px; background: #e6e6e6; border-radius: 3px; margin: 6px 0 4px; position: relative; } +.criterion .bar span { position: absolute; left: 0; top: 0; bottom: 0; background: #7d92ff; border-radius: 3px; } +.criterion .counts { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-3); } +.trust { background: var(--bg-2); border: 1px solid #c9d1ff; border-radius: 4px; padding: 12px 16px; font-weight: 700; color: var(--card-navy); display: flex; gap: 12px; align-items: center; margin: 12px 0; } +.review-head { display: flex; align-items: center; justify-content: space-between; margin: 16px 0 8px; } +.review-head h3 { font-size: 15px; letter-spacing: .5px; } +.review { padding: 14px 0; border-bottom: 1px solid var(--border); } +.review:last-child { border-bottom: 0; } +.review .text { margin: 6px 0; font-size: 15px; line-height: 1.5; } +.review .date { font-size: 13px; color: var(--text-3); } +.review .actions { display: flex; gap: 10px; align-items: center; margin-top: 6px; } +.review details summary { color: var(--blue); cursor: pointer; font-size: 14px; } +.review .crit-list { font-size: 13px; color: var(--text-3); margin: 6px 0; display: grid; grid-template-columns: 1fr 1fr; gap: 2px 12px; } +.pending { background: #fff7e0; border: 1px solid #f2d78a; padding: 12px 14px; border-radius: 4px; margin: 12px 0; font-size: 14px; } +.pending .tag { display: inline-block; background: #f5b400; color: #1b1b1b; font-weight: 700; font-size: 12px; padding: 2px 8px; border-radius: 3px; margin-left: 6px; } +details.review-form > summary { list-style: none; cursor: pointer; } +details.review-form > summary::-webkit-details-marker { display: none; } +.form-grid { display: grid; gap: 12px; margin-top: 14px; } +.form-grid label { font-size: 14px; font-weight: 600; } +.form-grid input[type=text], .form-grid input[type=email], .form-grid input[type=password], .form-grid input[type=date], .form-grid textarea, .form-grid select { width: 100%; padding: 10px 12px; border: 1px solid var(--border-2); border-radius: 3px; font-size: 15px; } +.form-grid textarea { min-height: 110px; resize: vertical; } +.star-pick { display: flex; gap: 12px; align-items: center; } +.star-pick label { display: inline-flex; align-items: center; gap: 4px; cursor: pointer; font-weight: 400; } +.crit-rows { display: grid; gap: 8px; } +.crit-row { display: grid; grid-template-columns: 1fr auto auto; gap: 14px; align-items: center; font-size: 14px; } +.crit-row label { font-weight: 400; display: inline-flex; gap: 4px; align-items: center; } +.cond { border: 1px solid var(--border); border-radius: 3px; padding: 12px 14px; margin-bottom: 8px; } +.cond .tier-bar { display: grid; grid-template-columns: repeat(3, 1fr); gap: 3px; margin: 8px 0 4px; } +.cond .tier-bar span { height: 8px; background: #bcbcbc; border-radius: 4px; } +.cond .tier-bar span.on { background: var(--card-navy); } +.cond .tier-bar span.on.similar { background: #7d92ff; } +.cond .tier-labels { display: grid; grid-template-columns: repeat(3, 1fr); font-size: 13px; text-align: center; color: var(--text-3); } +.cond .tier-labels .on { color: var(--card-navy); font-weight: 700; } +.top20 summary { color: var(--blue); cursor: pointer; text-decoration: underline; margin-top: 8px; } +.top20 ol { padding-left: 22px; margin: 8px 0 0; columns: 2; } +.top20 ol li { padding: 2px 0; } +.inline-list { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 20px; } +.inline-list li { padding: 2px 0; } +.award-box { display: flex; gap: 16px; align-items: flex-start; } +.award-box .seal { width: 64px; height: 64px; border-radius: 50%; border: 3px solid #c9d1ff; background: var(--bg-2); color: var(--card-navy); font-size: 9px; font-weight: 700; text-align: center; display: flex; align-items: center; justify-content: center; flex: none; line-height: 1.1; padding: 4px; } +.award-box h4 { font-size: 13px; letter-spacing: 1px; margin-bottom: 4px; } +.poll { border-bottom: 1px solid var(--border); padding: 12px 0; display: grid; grid-template-columns: 1fr auto; gap: 12px; align-items: center; } +.poll:last-child { border-bottom: 0; } +.poll .q { font-weight: 600; font-size: 14px; } +.poll .yn { display: flex; gap: 8px; margin-top: 6px; } +.poll .yn span { border: 1px solid var(--blue); color: var(--blue); border-radius: 12px; padding: 1px 12px; font-size: 13px; } +.poll .counts { display: flex; gap: 8px; align-items: center; font-size: 13px; } +.poll .counts .bar { width: 90px; height: 8px; background: #d0d0d0; border-radius: 4px; } +.kv h4 { font-size: 14px; margin: 12px 0 4px; } +.kv h5 { font-size: 12px; letter-spacing: 1px; color: var(--text-3); margin: 10px 0 4px; } +.faq dt { font-weight: 700; margin-top: 12px; } +.faq dd { margin: 4px 0 0; } +.city-links { display: flex; flex-wrap: wrap; gap: 8px 20px; } +.rail-list { display: flex; flex-direction: column; gap: 14px; } +.rail-row { display: flex; gap: 12px; align-items: flex-start; } +.rail-row .avatar { width: 56px; height: 56px; border-radius: 50%; flex: none; } +.rail-row .name { font-weight: 700; font-size: 15px; } +.rail-row .name a { color: var(--text-2); } +.rail-row .sub { font-size: 13px; color: var(--text-3); } +.rail-row .stars .ic { width: 13px; height: 13px; } +.flash { border-radius: 4px; padding: 10px 14px; margin-bottom: 14px; font-size: 15px; } +.flash.success { background: #e5f6ec; border: 1px solid #b6e3c6; color: #1c6b3a; } +.flash.info { background: var(--bg-2); border: 1px solid #c9d1ff; color: var(--card-navy); } +.flash.error { background: #fdeaea; border: 1px solid #f3b8b8; color: #8f1d1d; } +.save-form { display: inline; } +.save-btn { background: none; border: 0; color: #fff; display: inline-flex; align-items: center; gap: 6px; cursor: pointer; padding: 0; font-size: 15px; } +.save-btn .ic { width: 15px; height: 15px; } + +/* ---------- booking ---------- */ +.book-page { background: #fff; border: 1px solid var(--border); border-radius: 4px; padding: 28px 40px 60px; } +.book-doc { display: flex; align-items: center; gap: 16px; justify-content: center; margin: 12px 0 20px; } +.book-doc img { width: 80px; height: 80px; border-radius: 50%; } +.book-doc .n { font-size: 22px; font-weight: 700; } +.book-band { background: var(--bg-2); text-align: center; padding: 34px 20px; margin-bottom: 40px; } +.book-band h1 { color: var(--card-navy); font-size: 38px; margin-bottom: 8px; } +.book-band p { color: var(--card-navy); font-size: 22px; margin: 0; } +.steps { display: flex; align-items: center; justify-content: center; gap: 0; max-width: 620px; margin: 0 auto 40px; } +.steps span { width: 26px; height: 26px; border-radius: 50%; background: #c3c9e6; color: #fff; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; } +.steps span.on { background: var(--card-navy); } +.steps i { flex: 1; height: 2px; background: #d9dcea; } +.book-form { max-width: 620px; margin: 0 auto; } +.book-form h3 { font-size: 16px; margin: 20px 0 10px; } +.book-form .seg label { padding: 14px 16px; font-size: 16px; } +.book-form select { width: 100%; padding: 14px 16px; border: 1px solid var(--border-2); border-radius: 3px; font-size: 16px; } +.book-form .grid-days { max-width: 620px; } +.book-form .submit { display: flex; justify-content: flex-end; margin-top: 30px; } +.errors { background: #fdeaea; border: 1px solid #f3b8b8; color: #8f1d1d; border-radius: 4px; padding: 10px 14px; margin-bottom: 16px; } +.errors li { padding: 2px 0; } +.confirm-box { text-align: center; padding: 30px 0; } +.confirm-box .ref { font-size: 32px; font-weight: 700; color: var(--card-navy); margin: 10px 0 20px; letter-spacing: 1px; } +.confirm-box dl { display: inline-grid; grid-template-columns: auto auto; gap: 6px 18px; text-align: left; margin: 0 auto 24px; } +.confirm-box dt { font-weight: 700; } +.confirm-box dd { margin: 0; } + +/* ---------- auth ---------- */ +.auth-backdrop { background: rgba(0,0,0,.72); padding: 40px 0 80px; min-height: 70vh; } +.auth-modal { background: #fff; width: 1024px; max-width: 100%; margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; min-height: 636px; position: relative; } +.auth-art { background: linear-gradient(200deg, #1b3bd6, var(--navy) 80%); color: #fff; padding: 40px; display: flex; flex-direction: column; justify-content: flex-end; } +.auth-art h2 { font-size: 34px; font-weight: 400; margin-bottom: 10px; } +.auth-form { padding: 90px 50px 40px; } +.auth-form .top { text-align: center; margin-bottom: 30px; } +.auth-form .top h1 { font-size: 24px; margin-top: 6px; } +.auth-form input[type=email], .auth-form input[type=password], .auth-form input[type=date], .auth-form input[type=text] { width: 100%; padding: 14px 16px; border: 1px solid var(--border-2); border-radius: 3px; font-size: 16px; margin-bottom: 20px; } +.auth-form .btn { width: 100%; padding: 13px; font-size: 17px; } +.auth-form .remember { display: flex; align-items: center; gap: 8px; margin-top: 12px; font-size: 15px; } +.auth-form .forgot { display: block; margin: -10px 0 20px; } +.auth-close { position: absolute; right: 24px; top: 18px; font-size: 28px; color: var(--text-3); } + +/* ---------- specialty / hub pages ---------- */ +.page-title { font-size: 34px; font-weight: 700; color: var(--card-navy); margin-bottom: 6px; } +.page-title.dark { color: var(--text); font-weight: 400; } +.page-title.dark strong { font-weight: 700; color: var(--card-navy); } +.page-sub { color: var(--text-2); font-size: 15px; margin-bottom: 14px; } +.chip-row { display: flex; flex-wrap: wrap; gap: 10px; margin: 12px 0 24px; } +.chip-row a { border: 1px solid var(--blue); color: var(--blue); border-radius: 9999px; padding: 6px 18px; font-weight: 700; font-size: 14px; background: #fff; } +.chip-row a:hover { background: var(--bg-2); text-decoration: none; } +.h2 { font-size: 26px; font-weight: 700; color: var(--text); margin: 18px 0 14px; } +.stat-row { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin: 18px 0 30px; } +.stat { background: #fff; border: 1px solid var(--border); border-radius: 4px; display: grid; grid-template-columns: 220px 1fr; } +.stat .n { padding: 18px 20px; border-right: 1px solid var(--border); display: flex; align-items: center; gap: 10px; font-size: 40px; font-weight: 700; color: var(--card-navy); } +.stat .n small { font-size: 16px; font-weight: 600; line-height: 1.1; } +.stat .t { padding: 18px 20px; font-size: 14px; color: var(--text-2); display: flex; align-items: center; } +.alpha-bar { display: flex; flex-wrap: wrap; gap: 4px; justify-content: space-between; background: var(--bg); padding: 8px 12px; margin-bottom: 24px; font-weight: 600; } +.alpha-bar span { color: var(--muted); padding: 2px 6px; } +.alpha-bar a { padding: 2px 6px; border-bottom: 2px solid transparent; } +.alpha-bar a.on { border-color: var(--blue); } +.spec-index { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px 30px; } +.spec-index a { color: var(--text); font-size: 17px; } +.center-panel { background: #fff; border: 1px solid var(--border); border-radius: 4px; padding: 28px 36px 40px; } +.center-panel h1 { text-align: center; font-size: 26px; margin-bottom: 24px; } +.hub-card { background: #fff; border: 1px solid var(--border); border-radius: 4px; padding: 22px 26px; display: grid; grid-template-columns: 1fr 200px; gap: 20px; } +.hub-card h3 { font-size: 22px; } +.hub-card h3 a { color: var(--text); } +.hub-card .sub { font-size: 14px; color: var(--text-2); } +.hub-card .desc { font-size: 14px; color: var(--text-2); margin-top: 8px; } +.hub-hero { background: linear-gradient(90deg, var(--bg-2) 60%, #c9d1ff); border-radius: 4px 4px 0 0; padding: 24px 36px; } +.hub-hero h1 { color: var(--card-navy); font-size: 38px; } +.hub-hero.navy { background: var(--card-navy); } +.hub-hero.navy h1 { color: #fff; } +.hub-meta { background: #fff; padding: 18px 36px 24px; border: 1px solid var(--border); border-top: 0; margin-bottom: 20px; } +.hub-meta .stats { color: var(--blue); font-weight: 700; font-size: 15px; } +.hub-meta .stats span { margin-right: 14px; } +.phys-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px 24px; } +.phys-row { display: flex; gap: 12px; align-items: flex-start; } +.phys-row .avatar { width: 56px; height: 56px; border-radius: 50%; flex: none; } +.phys-row .name { font-weight: 700; } +.phys-row .name a { color: var(--text-2); } +.phys-row .sub { font-size: 13px; color: var(--text-3); } +.showing { color: var(--text-3); font-size: 14px; margin-bottom: 12px; } +.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 24px; } +.two-col li { padding: 3px 0; } +.flag-row { display: flex; gap: 20px; font-size: 14px; margin: 8px 0; } +.flag-row span { display: inline-flex; align-items: center; gap: 6px; } +.flag-row .ic { color: var(--green); } +.flag-row .ic.no { color: var(--muted); } +.awards-hero { background: var(--navy); color: #fff; padding: 40px 0; } +.awards-hero .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; align-items: center; } +.awards-hero h1 { font-size: 42px; font-weight: 700; margin-bottom: 10px; } +.awards-hero p { font-size: 20px; margin: 0; } +.awards-hero .seals { display: flex; gap: 14px; } +.awards-hero .seal { width: 110px; height: 110px; border-radius: 50%; background: #fff; color: var(--card-navy); display: flex; align-items: center; justify-content: center; text-align: center; font-size: 11px; font-weight: 700; line-height: 1.15; padding: 10px; border: 4px solid #c9d1ff; } +.awards-nav { background: #f6f6f6; border-bottom: 1px solid var(--border); } +.awards-nav ul { display: flex; justify-content: space-around; padding: 12px 0; font-weight: 600; } +.awards-method { background: var(--bg-2); text-align: center; padding: 50px 0; } +.awards-method h2 { font-size: 40px; color: var(--card-navy); margin-bottom: 14px; } +.awards-method p { max-width: 720px; margin: 0 auto; font-size: 20px; line-height: 1.4; color: var(--text-2); } +.awards-section { padding: 50px 0; background: #fff; } +.awards-section:nth-of-type(even) { background: #f7f7f7; } +.awards-section h2 { font-size: 34px; color: var(--card-navy); margin-bottom: 12px; } +.awards-section .links { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 30px; font-weight: 700; font-size: 18px; margin-top: 14px; max-width: 560px; } +.filters-line { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; font-size: 15px; } +.filters-line .tag { border: 1px solid var(--blue); color: var(--blue); border-radius: 9999px; padding: 6px 16px; font-weight: 600; } +.static-page h1 { font-size: 30px; margin-bottom: 16px; color: var(--card-navy); } +.static-page h2 { font-size: 20px; margin: 22px 0 8px; } +.static-page p, .static-page li { line-height: 1.55; color: var(--text-2); } +.static-page ul { list-style: disc; padding-left: 24px; } +.table { width: 100%; border-collapse: collapse; font-size: 15px; } +.table th, .table td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border); vertical-align: top; } +.table th { color: var(--text-3); font-weight: 600; font-size: 13px; letter-spacing: .5px; text-transform: uppercase; } +.empty { padding: 30px; text-align: center; color: var(--text-3); } + +/* ---------- footer ---------- */ +.footer-links { background: #fff; border-top: 1px solid var(--border); padding: 22px 0 30px; } +.footer-links .find { font-weight: 600; padding-bottom: 14px; border-bottom: 1px solid var(--border); margin-bottom: 22px; } +.footer-links .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } +.footer-links h4 { color: var(--blue); font-size: 15px; margin-bottom: 10px; } +.footer-links li { margin: 6px 0; } +.footer-links li a { color: var(--text-2); } +.footer-links .strong a { color: var(--blue); font-weight: 700; } +.site-footer { background: var(--navy); color: #fff; padding: 36px 0 30px; font-size: 14px; } +.site-footer .top { display: flex; gap: 40px; align-items: flex-start; padding-bottom: 30px; } +.site-footer .top h4 { font-size: 17px; margin-bottom: 14px; } +.site-footer .social { display: flex; gap: 18px; } +.site-footer .social img { width: 22px; height: 22px; display: block; } +.site-footer .divider { width: 1px; background: rgba(255,255,255,.35); align-self: stretch; } +.site-footer .apps { display: flex; gap: 10px; } +.site-footer .apps span { border: 1px solid #fff; border-radius: 4px; padding: 5px 10px; font-size: 12px; font-weight: 600; display: inline-flex; flex-direction: column; line-height: 1.1; } +.site-footer .apps span small { font-size: 9px; font-weight: 400; } +.site-footer .tabs-row { display: flex; gap: 8px; border-bottom: 1px solid rgba(255,255,255,.6); padding-bottom: 14px; margin-bottom: 14px; } +.site-footer .tabs-row a { color: #fff; padding: 6px 12px; font-weight: 600; } +.site-footer .tabs-row a.on { background: #c9d1ff; color: var(--navy); border-radius: 3px; } +.site-footer .policy { display: flex; flex-wrap: wrap; gap: 0; align-items: center; justify-content: space-between; } +.site-footer .policy ul { display: flex; flex-wrap: wrap; } +.site-footer .policy li { padding: 4px 14px; border-right: 1px solid rgba(255,255,255,.6); } +.site-footer .policy li:first-child { padding-left: 0; } +.site-footer .policy li:last-child { border-right: 0; } +.site-footer .policy a { color: #fff; font-weight: 600; } +.site-footer .legal { display: flex; justify-content: space-between; align-items: center; gap: 20px; margin-top: 30px; font-size: 12px; } +.site-footer .legal img { height: 26px; } + +@media (max-width: 1100px) { + .profile-hero, .profile-grid { grid-template-columns: 1fr; } + .profile-hero .hero-art { display: none; } + .rail { position: static; } + .spec-grid, .browse-grid, .spec-cols { grid-template-columns: repeat(3, 1fr); } + .mini-grid.six { grid-template-columns: repeat(3, 1fr); } + .auth-modal { grid-template-columns: 1fr; } + .auth-art { display: none; } + .home-promo .grid, .awards-band .grid, .awards-hero .grid { grid-template-columns: 1fr; } + .menu.menu-wide { min-width: 300px; column-count: 1; } +} +@media (max-width: 720px) { + .header-nav { gap: 14px; font-size: 14px; } + .search-bar { flex-direction: column; height: auto; } + .search-bar .field { padding: 12px 16px; border-right: 0; border-bottom: 1px solid var(--border); } + .search-bar button { padding: 14px; } + .hero h1 { font-size: 30px; } + .phys-card { flex-direction: column; } + .phys-card .cta-col { width: 100%; } + .profile-hero .hero-main { grid-template-columns: 1fr; } + .spec-grid, .browse-grid, .spec-cols, .mini-grid, .mini-grid.six, .spec-index, .phys-grid, .criteria-grid, .stat-row, .footer-links .cols { grid-template-columns: 1fr; } + .loc, .hub-card, .stat { grid-template-columns: 1fr; } + .grid-days { grid-template-columns: repeat(2, 1fr); } + .book-page { padding: 20px 16px 40px; } + .site-footer .top { flex-direction: column; } +} diff --git a/sites/webmd_doctor/static/fonts/source-sans-3-400.woff2 b/sites/webmd_doctor/static/fonts/source-sans-3-400.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..669e05f3cc4191bafb482501c31953b5e53eb302 GIT binary patch literal 15684 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

IZFNFB`^ zm*tWj=9UvSW!bTG4t)elbS&iG2X+$3BjqI@V!6kFAr`PeMSYSO*wP8y=E3y-Szas^6$4%jb(obO5Wi?r|VKS#oRiKw6bMTL;x z=~UQ2ZHm}XL<|a7y}pF>0?K54a5J!Q@CgVhuO5GneKx+-KQ2CM9^ps$TsRj`$8jI` z_N>e)yR|UlwqK7rwnijITfs>Q&N&Ij&%|8OnGB!vgCJxm`q-)a%i{M3V6#N5OOJB9<}3XG?r;>L|D9UV0T0}T^1EkAx}L6YdCC}cC%IB_a5?ny&< zQxQOy9S?Vq8P>IZvU*q))xly6uu%9$;;!Z@tb^{TnGn+tDU(L|Q6wrXR2R5CA?S&j zO-qGqg$V5;NDEPDhhor;ZAJShXE4%IqVVFKKwntYoyX6+)r+Xp|sdPRX7>UuXzNm{>_N) zl+olnR)aK{?NzKn`hQWE)Kq= z-mePbk$##GT%<~MDM?k@gQa_<4iYYlB>f^&WD?4eN2W+Ql`6Gp^_m&A>)~m@7=Ex~ z{Jstd>9^c7*G(_~|H8LwHba#2BURz_-ezC7ru$%$|f z0mEK+)TV$pvuKd=SkHq?Yw5U=TV?nlx#x&1mY%7^-%+u<>c|9b%c_WysM27ekfxCVgde+Ih-9}IR4?jAIL*!W@M zhwk@Zynl_whd6*ez&_xZ>A(%(El4*2kQO2l3t*|sp@eC$SZA}VKKj`QHgyue{pb8{ zseM*DWwkZ_@zQInU2)7A$Ng_l3^?JmlZK75-uJ#Sgb9(!VqcnK0E$(JHknsga5{mCoGCmeF+DbTM$qbAM9YS*Dtmu@}& z(r&;ABaJfJIAdh%tCEu7g?*rw+A&i6a3QF15&}s=Ksgy?Nig&a!05jKtM*Uq4e%hN zcq5Tz^|5%KXm$&jtV9RS>@aoCriHP}CFSHK82bS5%AyX${MJf2(h#|Pun zxlst@XM`N}xGfT$h?L$vING*MyW)qTq+!i-Q4h`~=2;Obspe|eEBIo(G!97MQ5vP^kex!=U6n!MC-L^1jniLyNWDc7)J> z(;;{QP%m~{tZBOzc1jdiI69)=p-Et8!pjm=ga~z@DFuR%2&9A5dtZSCm3cEYs#y0+b6NH1k3Tn{H_^_HjjKx;sVWy3KGC>P6S7Odvsxw@gWmqQ` z4xz|hLZ>aNs3G%SisRM5^0R`Lb0_66wc75@;m=8=?J(7u>x^5&;$unN3YAltq<-TO zDgn_njv>zekNJcqx#Izb5Xs!{H`;=M*v7&7`wNu|&PFmpJ=}7j?nGH5UdKB5CZQt- zRIPRcs+DU3Id-`X7>fgJ7L5A5H1HCiN+z9_xiNK3GY<|CNnNiHbg<0B4)=1Hw9<-FLb{x(F?gNX2h!=b>9T5zo0I?+j6JdzTEn z%pA`(K%$L<3#;UC;RxWkw=|n~O5DP5?oojp2v(;6%-ySj z6Rz@NI=KT4PGZKPh4*HbcUdf*H$2kl*no2fSVPx8z)sLB#}S}m#)o~{5+pNVlU{{ja1BtV82yhDTB~f#Kk> zVp`{aM=}lodheD*_JBY4qjvj~imzSVxY5=7gm9k_gL3P#My_;-9ZT44Q_=UCh3%u2 zek>A4ES@|i4JKIZS{H4ON_Ia+H{~Wx-$egZr!Nd-$rxZP1IuN~>{UvfnwlHx#jF+4jj6N>kBE zcDz-M#r6dzOwR4uIzhfCLK9N`L=lT^BVLK%CaO zWZ)2ETexZ!T{ov+Rs%}~ZhZyxc(h}4#YrrTO1=Omm@Pay+SYY6Me z&Z?a=>F8+NmDv+}9c8bPG1O#D!dC=EBI;gxTI`#G_n=hQ6NzLQL<1CY?=e%sWC^J? z=yLB44xJalD8jHOpYdee44c%QI11+jxY#VOiku3CL$F*a>6jz)e|e~pS5`(!jBaYb zN7x36>a2^4r^YS26?&ih-GmJg$ib|`LOzML7j$`brBDd{R|r3gos3w!QnmdI_5?dU zZYJgZg9H3W{C*XuOd%L*7% z3lC5Dt_=cozDk%5ITA#*NgeQU<&MEY^M*;>!s` zRbJY6W|0<;15V8StK~+=cHi|>Jh+$hQF#)duGSC((m@szdpQWQCZ=?z#F?XE7n?aMKh*8IctGsO+2o);75ap65siwB_5X*l-; zBS{I}m)KNkm`aQ|NhXsj#y(7kZMKr%O-oaJh1nP}Sun0(RG`Q@7-hNak4SBN=As+P zJ2Gp9yX|gvAJR(75cW5+lcuha&&Osb5$kkUy)qZln7oI?bsfGKgG{0O9#rN=^V40H z_Nin<0TL1=$wrcd9t4~xQ)g_ z+2-$g_bOrsN95Ddq0dwbGT}(^X893J!v`Wq=#ia-%w18k4B$Yl}X+~dY)_Chfn zjXfP=$;n0RFtzZ<*nGuLWJak{sn@ce7D(A8S%tKy?*|qhKd|%jD=8`FB}_C-w#13J zQoM-iAtI%3Vh)^wr^H8>GoKbJ2GTY5+|=Y_E%uji1C_k#dbSg+opLFKl`R1|NFKia zkfbQ`6}FG~-lBYpKG>x%a&Il2=;TRu?BuGdp??|LHh6Mk^>w1b6`Ws4HQr%?x{C5C zn!iiJ%L1E+@hS>_>#N@bF5G(9q%UB$``kQ^Y<^I$%wi*++G`Kn1|r=TzMBu4A)jk_ zJg~oy4xImam=WhUb^UX5(t^iI`pClO_eh`gAF*l%bL8@+kAgT>|5z3#?SgN4J|v8* zUs9>UwBc&HnS^4@X5hPU%!f+$>L9{HN_j8q7ey@Dw|%&rY4F~)w5AlZJ*5{K-b}5J zDAksyGr_L9PM%5Crn@7-e^a$EY2p;QhEGNQ={TgB*Rn&!g&a63+{xe@ zI%jNi8a89y{|W0`aC`gzmU~Kl`jK6kKik|mPZigIC&dgl2|3!Bbv1bq5PVzc@8$pD z1jr8Z2G;ZDv!`g`m4)L}qwF6^>LFRZ>k5)9)B)@v*1-qK!O*rc|)&)N&J^r7JZ`x&k3lcbU<^x08R| z)mkA0#dFuyeeAN%ZEUnI?Dy1!5tpY#QY-!;(U<4*TS$MRrvsp!~GlT9SCsmdtr42Hzrm9UlagtYZj%g$n`q=zb(8r^3@->ax)0R?_AM{JtC{JWQTmX)0jkMF(s zQxZ&vHcc(xl?AVZ)@8zLo_#jX^)}&RZIG1K=3w(5u55Yr{1$4p4GqfJm}J z5ILb)T;RWboM}|sV@Md^AS(neRB%Sfy0odux_E{^7@F?4EvDDdwsd+Z=+H!9!K7e7 z&>1mm)P+VtXCNq;R0xCOxksF1sk)HeqK&q4?X8f#Iue8}wun4Zu4@xZn&)k{EdjG; z5=;rt+Z8bgL$L$l`q#P86!S;<^om?-B>q&gDVvdk;Sp*jOA6c6Lh?D(-7@CeugylGSn4h1YPB_- zjI*~=iCl$A!X-RsEiYwMeoKahQfatW1pak=W&7Lql>?Yil5oDkNNMrR3iW%KQ8Sgp zu~J!4Prq-LhumT?7UY8q-!CYttvZ-#&NLr@He941Y^^HzX%1N3|M6d1Q|-d^rfz5; zyJ=svHe67Y_c)ScecYpyMrJ*TDY!8xJjX~_w@8Egms)bsLTO9n&Z6q1W{Y;#jA*T>98sQ39F1xAq7$asNd>7d>P?MJ}VhZo~nY(g{+!uI78 zc==Eb1L4TTo*+wqG2P96xJ51xvtA-efukF~N)8f00viD>K1m77=T zpP298m99N~kW~En&7!6ruqlxcO{3HXo3s8L16BHu&a4H&Yvy=M>_ex3tNiCyu}92x z+cbEF`M7oH%C~NT!sM)EI(o9lMtTZr>NWZa{-DxQqtVuV76koJ_oNZ_J5orYB!%Cx z+4sm{rGgx~2R`|3uel)>HOKcRwz;BrTW*LB6k_wzguzQF$gkyQ@&+!GY4kF{twfKm$UerdcR{0zdGqyj%_Wp zHp@CElH@;-oGNf596;c{@A?99KYt&v5Rgc7mRqwg+(^E0kG6Gqp@p)Rbtk)Z#}GOK zst7rwH!1N8LeoRGrA|Kk0YaQQcwS{B_t#ze{P`1LwX@vNBEc*Lrg(-t7w<-QS=2J*?K_#osnvBU+ zr!yQT7Mn7E-r1*atcZ1EGy_$~&q2t3>ZA7FF>w=5 z!0i`w;o0Pwvy!M2dHF${D9=6sQc>HW(T@+LxUJQyFks$*VSFgeGV-V)xt3B0^CkcH zREI)KjTm`zf|u63ybL6&t2NpQ{vd_aXjGaC{p2N4LpV@a$*s4fB;@Le3@6NDH4zwj zak#n)!=Ww3I<yS zWwlbg@2&imVndwNtq18p&-N@0vWzTPkiBwK zp*sZNn7&axYP2XV;eQi%3~k6_E%(N3M|e!!!zAG-B%cmmsBGP~W^L=n%8J%aYu2=G zD~D;__|CF&e0v9t-hpo~FT;0s1D|>Rm30Y*pdI=Mu z4DU4+ah1vfn$RXy8ypg-@i!{WRVn-x93b498*5L54lMh5;jd#&XXI%Er8B@yl*EG> zDCqpw#Z@w{(u_Ql!=WcWK6NHbY_L5~8*cdnrzm1cqZW&{vPuL{DDctLZqh_gC8G}s zMHoV^;}OUg*sK*oCc(}GF}JS=_$)<$!!3k$l9B=m*cw}e zS$>U5G9osySb^RZ679>xslLST1IGJ&t$GQyR5YGZUh_iI%Nl6pD)S`z!6oHe>BhB zE>$QkdZwAP=E@hv60Ii8=Y~{TWO8Sp^8yEcTsFpa(~dbe=^{eg=Msg@@)4syV@Yfo zn1;fLu{bd%Hv@;w0H)1`L4h$L(edeQTM~wc#d`PxJY+O>8C*X7*HD|N%Hc@-G@FQ|9YwDmg#~N{b$a!V)D1Bu(U_9yc>&#f z@TnY)EUN7)EaITx`AZU#vPNU_{s8$q>Y@(L7p9da5{iV2~0GD>^ zYAsiKdo6$HY8a|=<&}vOmA|VhKy+s{Z-w%Aam8^N`Wo{e@jJ$R1(Xkz?iF;t>l6U| zfS}`DhhR@BxH_Qi_t11MGL9&blCA${lHEF*w@=FxV3~)7ytuGLG?)k_4hh9^VZQJ% z6Z=Fn&kyB8VX<)N1@vOjU-C40=qpda)%JO5IyX7kq?9bDMd&MOMP9*4&*o>~-X=ff;j4a?gAI0iH0d)k+J4d=dL%R2n;krB>f(>5UFKWdrhj>5t&CUQ!?u7i!evXwf9n0+FWH zhzrxCY=l9#3j{g=!ep~eO!xen7+1c{ApTCv(8cqDe6odQt{~L5)0C zUJDk=9sG8z2RelE+U;`1$MKDj{iI+J(B)_1V=#Z2;{#r0v20dh9X#0Hu~-+bn;-Nq ztT9n))g~*6%VIuHk zX;roIlZcJo=5;kO^gMrdddH!%TuUlI)xO5o&- zTa(Af6MrI;AE{X4NjRLJlszT3YQ-rQX-Nu+T=7Q&kwFdL25+z6t&qsCt-8vmlZM{d z`@jK|#LoF(4LgNIzDQ%v+-j@X!XUO%C@y-=x=#rWHoV$kR?@J8;GzzBbRpW2cjpDAh0ULGMhq>L*DWWls1kk@EEDL=Cbn2uz7k6J`Ht_bPkI-^6#<= zQQlIp2u0qF-S&)&iYzSrXKQPrV)jo-wMILs)<8oUzYws*d!8Q5ELACa)%3vmvn9l7 zCS}zuB=Z?T};UfyQj>tr1w4P%2igxbzkI0u5Xi+Gd4-aN@#J_IDt;iy zRm$C*3171?6qyQ^FtY4f9-fq7{S(Ty&B>RVV&6CTVPH@?hz!a;A5<5*w>-d_9ziiU z_(#u{@R0fo*$^@$9U1^8l)crfUC_{=U)bieOFg{sSv8g{vm5B0fgr8hZj{i*8!xQ# z%W57A5Qrgd?iQFKuM{xNmL{dP+2es*#>!!lnbhBo#Nk8^Hp4CTM#`L&PlJ-Tev-Ft zhja(nfNI*PY}yEZZa95Ff8aDc_XBin{dPSln?Ag3)v}di!O~R)E0>ioUHx+R(l?5? znA45s?M@PA3ASnZ~rO%m)12yZwOvz%V>)wcDTd zK98%x4t(f%q}h!-g}sodcbWs>1Gt+#jc9ZWxOm*iBI192TQs{g^~W79_^jb{%ZA4> z3asBo{vwyo$elwbf52kUSu=2?l@!`al06O@WhIz-0>Rz)#ivC<2Ik=5^S`oEq0y-~ z_sc#A#F)giU#kz84;E*X>hu-?xQ}Cl8WrIhv{Dex2k7d zv3+gXz;Ma6U%>LC`s$;9n}-5)y)i*T;udhcv6HS|mSsxFAo5T5f0yN1 z==GdpS-y!;LEwZrf4b!4xUbZYG|=PD+LDRh99(!QI&;~Nu8&<9`cc?{>;)@Tll$zX z8A`oSAL2;mSUY0x0?SW!PqYsj5-~zIL+)v)v)jFGarTKw58cz=41>1M<87lrzzmoH z)&Z^%MH9qH+bvj13#Oe_kHr4>rA zA`x`Dop7-cdRz=+xnPXoIWLB{RFop(mhUg#nY7BZtjF-UwbvPWYu18xn$)~w-I@sTXwglS#l?B23Ts!41?ztY8KtKIS?dKigDFTFLF^>nv2 z+I^7;ucKSBAA{_5!1SXTcE{b*$|ia3UE{9ZJ>8KBK6@L>aKTJ0e`rSrY3x-+JL4H0 zJeG`(M_<7VXdcPQuDB>7)&ZQFBKe- z9H(Oh_z9%}`;5R8-WC>przH0!Cihh?W=t+-^i&3NMlC>lT>m*9t6PD6n2v}DVN6Pr z@}x4UQ92T3cSR&?Mf6EYQm!-F&q$DX<8n5CX{SdX{iscB6LlT=cs)U?-9{T!>K zR^0Z5nQIB!+hGQ1wZqK&7tWfUeS0p>y)%B^Eg)yM5@dU%1cR(1I)3E?fVLB1f8`M$ zFCJ-Glv`S7n`&cw++G}xw*N1JUA`+!ci-Kbp$8rlZF0N1U<+Aos~v252`Qm8p?1}t z+E)kaP#vjbb)rt`oy!j-d<8PU-txntt(hF27|edCC(ysyE#Druc8I0V?{4duT)y3@ zJ?^a2Pl}bj8+!3Sv>^RqQ7%H=pu(kENv)!kOevIFxsWz>1=%mZ1Gxr&{{heo|Iz&S z>wo8i`xoHgQ^v}PnDFMmyjWs>0hk1U6~M(geqUzpFy1cVpo~AM%I9-+Wnb-7TU!ap z(`YepK0QbNR_OF7_Y2@2f}`n$`_wHUeDB7QiGAX`TUZGw^V5{g)eV|gq2tpcaDJcb z1bM>ck?N%_Ea1M`y$?_akN$FxeEtB&{h04+9qy8&)AKASl z^l01Zk}-uQJgJYkx*aG8JwI4%v2e0U74~W09dRDb5p!@En!sSsVK~oLMA4YSEQ~k} zHa-|4O9AB=IE0-K!3tZ;!065PNn@RHecolp-tk^xFuj^|R#;f>-_&H5Jx$vbS>@?& z@&4xZtjK35)a)pF=W-V>p^x8u-7faP)3Q3Left|bYUTT@%LSWJi!%>FH3J*oqsEQP z+8t=l6kE8(Z5oSlyWfe`sts>$Wh)rhy4S$@YrFw`!N7V1oBDbApYX33{(l~S$#nK% z-~`2t0JSgY+bzLQYlYhX*;sri;Q5<`*MEF!3mC| z%Q2K3gO3$Q{KZOhG?coNQ{wCy{S>b}c zM#1^ieGe;dnAu@H+1X8i6c?Y6>m*llzJrlE6#2t3@{RP+O#HRCKXxd!?mh+|{V7nGne)_$@xr74imt@paBUip=+W`R7Fp0%E7kNi696%Rw-q0&b!=k6IDO&q^uSd$6#+KHgaK zoT2>Nkz&S$*X#nkIdYv95dGu+(HMM)LAU{i~Kn3Hv#WZ0X|r$*;t*m^cLts%V>q$ z(qwa9tIqWKjspQ%Q67d4y$*tJufl*Hv*A#S4d86Y-6Udd`Xok>k(0Qbn3IGULz4_e z7EY4F6dWR8r+*xTfJY8maeh+vIKV2M+EwdNs9v*1P5PwjQ>{(2+U2-DUBdYB(<{9m z&2W9Jef7Gw^7%UTXyhkbk_@@z`ejh7UM}%Ddj&`|GP@qmGP2D1aHxNM@_ucq^(auU zhtPFy06or^RUn&iP3ZTj^|}FpzxanFoWE<$Citig>Wx$n8-oHi89f^6^=VF9p+gQI zU$GLz$rdg#Tr4j$*(IxQWec7+R-H~Y*R?#hN=Dp{^;I1f-{QG;f6kLPTFz+Jr%9~z zmfj7Rt2;?jnbEf+OSN|L>pWI69X!qj%=D_7`)q!UtO!&_?o7-)c(U-~&F3>Siobww z$1YHiU?D<<2^S&qvuq@jYqMm#eEDB~p(4dflqyqhpIdI&uSTtf>fBN9rrTlgz+Ly; z4~GU%JoLx`jTSlRxu>3K@~w})kAN1XMI6}JBMkS<%KlW*+PZI^1COVN%5nf z971!WvqY?FTTLlkgh)}vo9Crh=6mg(_eF~VGeMYHSlQS)IJvlac=`AR1cihZh$s|Q zq*#ekWy)2kRHa&tA7Ts`GGfexDKq9QC^~wUtXQ*QyKa@7oVbQU`zLeF{)OpPXJ@tT z)AFcO>QOA|x;`FT_xYn)j6HrAeutWGrVVM&e({-#KR5;3oBk!IpCxA2AHTx5`DZri z{JKtt^qZ(RO8O1a8%Ogq>NKCqR<)g$ltxoFVG~AEri@lc=k{yPnV&4CYj@YFLY?zh zHE(>~`nlE~*33mwN)wt8rKB{WY9+KqO53#4-P)aN@4u%z6QJDZTkRI&4mGJ>WdzazuKM{_j`FAqT~X6DO}#PRcp^I{4s{OSoox(!=l@G4sNb*ZdPBLKYc?30002XoM{jM literal 0 HcmV?d00001 diff --git a/sites/webmd_doctor/static/fonts/source-sans-3-600.woff2 b/sites/webmd_doctor/static/fonts/source-sans-3-600.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..053da4e321ff08ada10919feaea0c7e0410febb2 GIT binary patch literal 15696 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 CdJ?Pv literal 0 HcmV?d00001 diff --git a/sites/webmd_doctor/static/fonts/source-sans-3-700.woff2 b/sites/webmd_doctor/static/fonts/source-sans-3-700.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..2801ed4dd17054d8320fb076d1b88c48b8d79437 GIT binary patch literal 15640 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 literal 0 HcmV?d00001 diff --git a/sites/webmd_doctor/static/icons/favicon.svg b/sites/webmd_doctor/static/icons/favicon.svg new file mode 100644 index 00000000..ea35c5e5 --- /dev/null +++ b/sites/webmd_doctor/static/icons/favicon.svg @@ -0,0 +1 @@ +W diff --git a/sites/webmd_doctor/static/icons/logo-webmd-care.svg b/sites/webmd_doctor/static/icons/logo-webmd-care.svg new file mode 100644 index 00000000..998c5a79 --- /dev/null +++ b/sites/webmd_doctor/static/icons/logo-webmd-care.svg @@ -0,0 +1,3 @@ + + WebMDCare® + diff --git a/sites/webmd_doctor/static/icons/social-facebook.svg b/sites/webmd_doctor/static/icons/social-facebook.svg new file mode 100644 index 00000000..afadc73c --- /dev/null +++ b/sites/webmd_doctor/static/icons/social-facebook.svg @@ -0,0 +1 @@ + diff --git a/sites/webmd_doctor/static/icons/social-instagram.svg b/sites/webmd_doctor/static/icons/social-instagram.svg new file mode 100644 index 00000000..906c07ac --- /dev/null +++ b/sites/webmd_doctor/static/icons/social-instagram.svg @@ -0,0 +1 @@ + diff --git a/sites/webmd_doctor/static/icons/social-pinterest.svg b/sites/webmd_doctor/static/icons/social-pinterest.svg new file mode 100644 index 00000000..094e6042 --- /dev/null +++ b/sites/webmd_doctor/static/icons/social-pinterest.svg @@ -0,0 +1 @@ + diff --git a/sites/webmd_doctor/static/icons/social-tiktok.svg b/sites/webmd_doctor/static/icons/social-tiktok.svg new file mode 100644 index 00000000..fc7187a2 --- /dev/null +++ b/sites/webmd_doctor/static/icons/social-tiktok.svg @@ -0,0 +1 @@ + diff --git a/sites/webmd_doctor/static/icons/social-whatsapp.svg b/sites/webmd_doctor/static/icons/social-whatsapp.svg new file mode 100644 index 00000000..fa7d87d7 --- /dev/null +++ b/sites/webmd_doctor/static/icons/social-whatsapp.svg @@ -0,0 +1 @@ + diff --git a/sites/webmd_doctor/static/icons/social-x.svg b/sites/webmd_doctor/static/icons/social-x.svg new file mode 100644 index 00000000..bb892741 --- /dev/null +++ b/sites/webmd_doctor/static/icons/social-x.svg @@ -0,0 +1 @@ + diff --git a/sites/webmd_doctor/static/icons/webmd-logo-white.svg b/sites/webmd_doctor/static/icons/webmd-logo-white.svg new file mode 100644 index 00000000..71ff2836 --- /dev/null +++ b/sites/webmd_doctor/static/icons/webmd-logo-white.svg @@ -0,0 +1,4 @@ + + WebMD + ® + diff --git a/sites/webmd_doctor/static/js/site.js b/sites/webmd_doctor/static/js/site.js new file mode 100644 index 00000000..1b6aa123 --- /dev/null +++ b/sites/webmd_doctor/static/js/site.js @@ -0,0 +1,125 @@ +/* WebMD Care mirror — small progressive enhancements (menus, popovers, typeahead). */ +(function () { + "use strict"; + + function closeAll(except) { + document.querySelectorAll(".menu.open, .popover.open, .typeahead.open").forEach(function (el) { + if (el !== except) { el.classList.remove("open"); } + }); + } + + document.addEventListener("click", function (event) { + var toggle = event.target.closest("[data-toggle]"); + if (toggle) { + event.preventDefault(); + var target = document.getElementById(toggle.getAttribute("data-toggle")); + if (target) { + var willOpen = !target.classList.contains("open"); + closeAll(target); + target.classList.toggle("open", willOpen); + toggle.setAttribute("aria-expanded", willOpen ? "true" : "false"); + } + return; + } + if (!event.target.closest(".menu, .popover, .typeahead, .search-bar .field")) { + closeAll(null); + } + }); + + document.addEventListener("keydown", function (event) { + if (event.key === "Escape") { closeAll(null); } + }); + + // Filter bar: submit as soon as a choice changes (checkbox pills, popover radios, selects). + document.querySelectorAll("form.filter-form").forEach(function (form) { + form.querySelectorAll("input[type=checkbox], input[type=radio], select").forEach(function (input) { + input.addEventListener("change", function () { form.submit(); }); + }); + }); + + // Booking grid: highlight the checked slot / segment. + document.querySelectorAll(".slot input, .seg input").forEach(function (input) { + input.addEventListener("change", function () { + var group = input.closest(".grid-days, .seg"); + if (group) { + group.querySelectorAll(".on").forEach(function (el) { el.classList.remove("on"); }); + } + input.closest("label").classList.add("on"); + }); + if (input.checked) { input.closest("label").classList.add("on"); } + }); + + // Search typeahead over specialties / conditions / practices embedded in the page. + var dataNode = document.getElementById("typeahead-data"); + if (dataNode) { + var vocab = JSON.parse(dataNode.textContent); + document.querySelectorAll("input[data-typeahead]").forEach(function (input) { + var box = document.getElementById(input.getAttribute("data-typeahead")); + if (!box) { return; } + input.addEventListener("input", function () { + var term = input.value.trim().toLowerCase(); + box.innerHTML = ""; + if (term.length < 2) { box.classList.remove("open"); return; } + var sections = [["SPECIALTY", vocab.specialty], ["CONDITION", vocab.condition], ["PRACTICE", vocab.practice]]; + var total = 0; + sections.forEach(function (section) { + var hits = section[1].filter(function (item) { return item.label.toLowerCase().indexOf(term) === 0; }).slice(0, 5); + if (!hits.length) { return; } + var head = document.createElement("div"); + head.className = "ta-section"; + head.textContent = section[0]; + box.appendChild(head); + hits.forEach(function (item) { + var link = document.createElement("a"); + link.href = item.href; + link.textContent = item.label; + box.appendChild(link); + total += 1; + }); + }); + box.classList.toggle("open", total > 0); + }); + }); + } + + // Location typeahead (seeded cities + zips). + var locNode = document.getElementById("location-data"); + if (locNode) { + var places = JSON.parse(locNode.textContent); + document.querySelectorAll("input[data-location]").forEach(function (input) { + var box = document.getElementById(input.getAttribute("data-location")); + if (!box) { return; } + function render() { + var term = input.value.trim().toLowerCase(); + box.innerHTML = ""; + var hits = places.filter(function (item) { return term.length === 0 || item.label.toLowerCase().indexOf(term) !== -1; }).slice(0, 8); + hits.forEach(function (item) { + var link = document.createElement("a"); + link.href = "#"; + link.textContent = item.label; + link.addEventListener("click", function (event) { + event.preventDefault(); + input.value = item.label; + box.classList.remove("open"); + }); + box.appendChild(link); + }); + box.classList.toggle("open", hits.length > 0); + } + input.addEventListener("input", render); + input.addEventListener("focus", render); + }); + } + + // Overview "View more". + document.querySelectorAll("[data-expand]").forEach(function (button) { + button.addEventListener("click", function (event) { + event.preventDefault(); + var target = document.getElementById(button.getAttribute("data-expand")); + if (target) { + target.classList.toggle("expanded"); + button.textContent = target.classList.contains("expanded") ? "View less" : "View more"; + } + }); + }); +})(); diff --git a/sites/webmd_doctor/templates/404.html b/sites/webmd_doctor/templates/404.html new file mode 100644 index 00000000..458a581e --- /dev/null +++ b/sites/webmd_doctor/templates/404.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} +{% block title %}Page Not Found{% endblock %} +{% block content %} +

+{% endblock %} diff --git a/sites/webmd_doctor/templates/500.html b/sites/webmd_doctor/templates/500.html new file mode 100644 index 00000000..3b188102 --- /dev/null +++ b/sites/webmd_doctor/templates/500.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} +{% block title %}Something went wrong{% endblock %} +{% block content %} +

Something went wrong

Please try again or return to the home page.

+{% endblock %} diff --git a/sites/webmd_doctor/templates/_filter_bar.html b/sites/webmd_doctor/templates/_filter_bar.html new file mode 100644 index 00000000..9cfccfe0 --- /dev/null +++ b/sites/webmd_doctor/templates/_filter_bar.html @@ -0,0 +1,81 @@ +{# results-style filter bar; expects `filter_bar` (params, insurers, options, qs()) and `base_path` #} +{% set p = filter_bar.params %} +
+ + {% if p.loc_label %}{% endif %} + {% if p.sids_explicit %}{% endif %} + {% if p.cid %}{% endif %} + {% if p.pid %}{% endif %} +
+
+ +
+ {% for key, label in filter_bar.sort_options %} + {% if key != 'distance' or filter_bar.show_distance %} + + {% endif %} + {% endfor %} +
+
+
+
+ +
+ + {% for n in (5, 4, 3, 2, 1) %} + + {% endfor %} +
+
+
+ +
+
+ +
+ + + + +
+
+ {% if filter_bar.show_distance %} +
+ +
+ + {% for miles in filter_bar.distance_options %}{% endfor %} +
+
+ {% endif %} +
+ +
+ + {% for low in ('5', '15', '20', '25', '30') %} + + {% endfor %} + + + + + +
+
+
+ +
+ + + + +
+
+
+ +
+
+
diff --git a/sites/webmd_doctor/templates/_footer.html b/sites/webmd_doctor/templates/_footer.html new file mode 100644 index 00000000..76f57d1d --- /dev/null +++ b/sites/webmd_doctor/templates/_footer.html @@ -0,0 +1,54 @@ + + diff --git a/sites/webmd_doctor/templates/_header.html b/sites/webmd_doctor/templates/_header.html new file mode 100644 index 00000000..c44039ea --- /dev/null +++ b/sites/webmd_doctor/templates/_header.html @@ -0,0 +1,48 @@ + diff --git a/sites/webmd_doctor/templates/_icons.html b/sites/webmd_doctor/templates/_icons.html new file mode 100644 index 00000000..6f379a76 --- /dev/null +++ b/sites/webmd_doctor/templates/_icons.html @@ -0,0 +1,35 @@ + diff --git a/sites/webmd_doctor/templates/_mini_card.html b/sites/webmd_doctor/templates/_mini_card.html new file mode 100644 index 00000000..3a54fff6 --- /dev/null +++ b/sites/webmd_doctor/templates/_mini_card.html @@ -0,0 +1,12 @@ +
+ +
{{ doctor.display_name }}
+
{{ doctor.primary_specialty.name }}
+
+ {{ doctor.avg_rating | rating1 }} + {% with rating=doctor.avg_rating, small=true %}{% include "_stars.html" %}{% endwith %} + ({{ doctor.ratings_count | plural_word('Rating') }}) +
+
{{ doctor.years_experience }} Years Exp{% if doctor.awards %} · {{ doctor.awards | length | plural_word('Award') }}{% endif %}
+ View Profile +
diff --git a/sites/webmd_doctor/templates/_pagination.html b/sites/webmd_doctor/templates/_pagination.html new file mode 100644 index 00000000..b3e5d0d7 --- /dev/null +++ b/sites/webmd_doctor/templates/_pagination.html @@ -0,0 +1,12 @@ +{# expects `page` dict, `base_path` and a `page_qs(n)` callable returning the query string #} +{% if page.pages > 1 %} + +{% endif %} diff --git a/sites/webmd_doctor/templates/_physician_card.html b/sites/webmd_doctor/templates/_physician_card.html new file mode 100644 index 00000000..04716a86 --- /dev/null +++ b/sites/webmd_doctor/templates/_physician_card.html @@ -0,0 +1,37 @@ +{# one shared physician card: `doctor`, optional `distance`, optional `award_line`, optional `show_remove` #} +{% set loc = doctor.primary_location %} +
+
+ {{ doctor.display_name }} + {% if doctor.is_enhanced %} VERIFIED{% endif %} +
+
+

{{ doctor.display_name }}

+
{{ doctor.primary_specialty.name }}
+
+ {% if doctor.avg_rating is not none %}{{ doctor.avg_rating | rating1 }}{% endif %} + {% with rating=doctor.avg_rating %}{% include "_stars.html" %}{% endwith %} + ({{ doctor.ratings_count | plural_word('Rating') }}) +
+
    + {% for line in doctor.award_lines %}
  • {{ line }}
  • {% endfor %} + {% if doctor.is_enhanced and doctor.callout_label %}
  • {{ doctor.callout_label }}
  • {% endif %} +
  • {{ doctor.years_experience }} Years Experience
  • + {% if doctor.is_enhanced %}
  • {{ 'Accepting New Patients' if doctor.accepting_new_patients else 'Not Accepting New Patients' }}
  • {% endif %} +
+ {% if doctor.is_enhanced and doctor.virtual_visit %}Telehealth Available{% endif %} +
{{ loc.address_line }}{% if distance is defined and distance is not none %}{{ distance | miles }}{% endif %}
+

"{{ doctor.card_snippet }}"

+ {% if show_remove %} +
+
+
+ {% endif %} +
+ {% if doctor.is_enhanced %} + + {% endif %} +
diff --git a/sites/webmd_doctor/templates/_search_bar.html b/sites/webmd_doctor/templates/_search_bar.html new file mode 100644 index 00000000..0b0fe59b --- /dev/null +++ b/sites/webmd_doctor/templates/_search_bar.html @@ -0,0 +1,13 @@ + diff --git a/sites/webmd_doctor/templates/_stars.html b/sites/webmd_doctor/templates/_stars.html new file mode 100644 index 00000000..11b42e48 --- /dev/null +++ b/sites/webmd_doctor/templates/_stars.html @@ -0,0 +1 @@ +{% for kind in rating | stars %}{% endfor %} diff --git a/sites/webmd_doctor/templates/account_appointments.html b/sites/webmd_doctor/templates/account_appointments.html new file mode 100644 index 00000000..2020e501 --- /dev/null +++ b/sites/webmd_doctor/templates/account_appointments.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}Appointments{% endblock %} +{% block content %} +
+

Appointments

+

Appointment requests submitted from your account, {{ current_user.display_name }}.

+
+ {% if rows %} + + + + {% for row in rows %} + + {% endfor %} + +
ReferenceProviderLocationPatientRequested time
{{ row.reference }}{{ row.doctor.display_name }}{{ row.location.name }}
{{ row.location.short_address }}
{{ row.patient_type }}{{ row.slot_label }}
+ {% else %} +
You have no appointment requests yet.
+ {% endif %} +
+
+{% endblock %} diff --git a/sites/webmd_doctor/templates/account_saved.html b/sites/webmd_doctor/templates/account_saved.html new file mode 100644 index 00000000..49dcca69 --- /dev/null +++ b/sites/webmd_doctor/templates/account_saved.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}Saved Providers{% endblock %} +{% block content %} +
+

Saved Providers

+

{{ rows | length | plural_word('provider') }} saved to your account, {{ current_user.display_name }}.

+ {% if rows %} +
+ {% for row in rows %}{% with doctor=row.doctor, show_remove=true %}{% include "_physician_card.html" %}{% endwith %}{% endfor %} +
+ {% else %} +
You have not saved any providers yet. Use "Save Provider" on a profile to add one.
+ {% endif %} +
+{% endblock %} diff --git a/sites/webmd_doctor/templates/award_recipients.html b/sites/webmd_doctor/templates/award_recipients.html new file mode 100644 index 00000000..45c1ea4a --- /dev/null +++ b/sites/webmd_doctor/templates/award_recipients.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}{{ class_title }} Award Recipients{% endblock %} +{% block content %} +
+ +

{{ class_title }} Award 2025–2026

+

WebMD Choice Awards let you find the providers recognized by patients and health care professionals in the 2025–2026 cycle. Open a provider's profile for their full details.

+
Filters: + {% for key, value in award_classes.items() %}{{ value[2] }}{% if key == award_class %} ✕{% endif %}{% endfor %} +
+ +
+ {% for doctor in page.rows %}{% include "_physician_card.html" %}{% endfor %} +
+ {% include "_pagination.html" %} +
+{% endblock %} diff --git a/sites/webmd_doctor/templates/awards.html b/sites/webmd_doctor/templates/awards.html new file mode 100644 index 00000000..3d306e10 --- /dev/null +++ b/sites/webmd_doctor/templates/awards.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% set hide_search_row = true %} +{% block title %}WebMD Choice Awards{% endblock %} +{% block content %} +
+
+
+

Best Hospitals According to Patients & Health Care Providers

+

Introducing the 2025–2026 WebMD Choice Awards

+
+
WebMD ELITE CHOICEWebMD PATIENT CHOICEMedscape PROVIDER CHOICE
+
+
+ +
+

Methodology Matters

You need information that you can trust when seeking care. The WebMD Choice Awards program is the only healthcare recognition program based solely on the vote of patients and providers within the last year. That's it. No complicated formulas here. Because we believe that finding best-in-class care should be easy.

+
+
+
+

Specialty Awards

+

The WebMD Choice Awards recognizes providers who deliver superior care in key specialties. Click the links below to view award recipients by specialty.

+ +
+
+
+
+

Awards by Class

+

Providers receive one of three distinct awards based on their WebMD Choice Awards ranking: WebMD Elite Choice for the providers preferred by patients and physicians two-to-one over competitors in their local market, WebMD Patient's Choice for providers in the top 30% of patient preferences, and Medscape Provider Choice for providers in the top 30% of health care provider preferences.

+ +
+
+{% endblock %} diff --git a/sites/webmd_doctor/templates/base.html b/sites/webmd_doctor/templates/base.html new file mode 100644 index 00000000..8b52e484 --- /dev/null +++ b/sites/webmd_doctor/templates/base.html @@ -0,0 +1,29 @@ + + + + + + {% block title %}Find Doctors and Dentists Near You{% endblock %} | {{ site_name }} + + + + + +{% include "_icons.html" %} +{% include "_header.html" %} +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %}
{{ message }}
{% endfor %} +
+ {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+{% include "_footer.html" %} + +{% block scripts %}{% endblock %} + + + diff --git a/sites/webmd_doctor/templates/book.html b/sites/webmd_doctor/templates/book.html new file mode 100644 index 00000000..ea473008 --- /dev/null +++ b/sites/webmd_doctor/templates/book.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% set hide_search_row = true %} +{% block title %}Request an Appointment with {{ doctor.display_name }}{% endblock %} +{% block content %} +
+
+ Return to profile +
+ +
{{ doctor.display_name }}
{{ doctor.specialty_names | join(', ') }}
+
+

Request An Appointment

How can we help you?

+
123
+
+ + {% if errors %}
    {% for error in errors %}
  • {{ error }}
  • {% endfor %}
{% endif %} +

This appointment is for:

+
+ {% for kind in patient_types %}{% endfor %} +
+

Location:

+ +

Choose a time:

+
September 2026
+ {% set chosen_slot = form.slot or request.args.get('slot', '') %} +
+ {% for abbr, short, day, label in booking_days %} +
+
{{ abbr }}{{ short }}
+ {% for slot in booking_slots %} + {% set value = day.isoformat() ~ '|' ~ slot %} + + {% endfor %} +
+ {% endfor %} +
+
+
+
+ +
+{% endblock %} diff --git a/sites/webmd_doctor/templates/book_confirm.html b/sites/webmd_doctor/templates/book_confirm.html new file mode 100644 index 00000000..0feb46f5 --- /dev/null +++ b/sites/webmd_doctor/templates/book_confirm.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% set hide_search_row = true %} +{% block title %}Appointment Requested{% endblock %} +{% block content %} +
+
+

Appointment Requested

Your request has been sent to {{ doctor.display_name }}

+
123
+
+
Confirmation reference
+
{{ booking.reference }}
+
+
Provider
{{ doctor.display_name }}
+
Location
{{ booking.location.name }}
{{ booking.location.short_address }}
+
Patient
{{ booking.patient_type }}
+
Requested time
{{ booking.slot_label }}
+
+

The office will confirm your request by phone. You can review it any time under My Account › Appointments.

+ View My Appointments +
+
+
+{% endblock %} diff --git a/sites/webmd_doctor/templates/doctor.html b/sites/webmd_doctor/templates/doctor.html new file mode 100644 index 00000000..851ba7de --- /dev/null +++ b/sites/webmd_doctor/templates/doctor.html @@ -0,0 +1,402 @@ +{% extends "base.html" %} +{% block title %}{{ doctor.display_name }} - {{ doctor.primary_specialty.name }} - {{ primary.city.name }}, {{ primary.city.state }}{% endblock %} +{% block meta_description %}{{ doctor.display_name }} is a {{ doctor.primary_specialty.name }} provider in {{ primary.city.name }}, {{ primary.city.state }} with {{ doctor.years_experience }} years of experience.{% endblock %} +{% block content %} +
+ + +
+
+
+ {{ doctor.display_name }} + {% if doctor.is_enhanced %} VERIFIED{% endif %} +
+
+

{{ doctor.display_name }}

+
{{ doctor.primary_specialty.name }}
+
+ {{ doctor.avg_rating | rating1 }} + {% with rating=doctor.avg_rating %}{% include "_stars.html" %}{% endwith %} + ({{ doctor.ratings_count | plural_word('Rating') }}) + Leave a review +
+ {% if doctor.accepting_new_patients %}Accepting New Patients{% endif %} + {% if doctor.is_enhanced %} +
Next Available: {{ doctor.next_available_label }} See full list of appointments
+ {% endif %} + {{ primary.phone }} + +
+ +
+ {% if doctor.hospital %}{% endif %} + +
+ + + +
+
+ {% if doctor.is_enhanced %} +
+

Provider Video

+
Provider video for {{ doctor.display_name }}
+
+ {% endif %} + +
+

Overview

+
+ {% if doctor.bio_html %} +
{{ doctor.bio_html | safe }}
+ View more + {% else %} +

{{ doctor.overview_text }}

+

Overview based on verified provider data

+ {% endif %} +
+
+ + {% if doctor.featured_review %} +
+

Featured Review

+

"{{ doctor.featured_review.text }}"

Read all reviews
+
+ {% endif %} + +
+

Locations

+
+ {% for location in doctor.locations %} +
+
+ {{ location.practice.name }} +
{{ location.name }}
+
{{ location.street }}
{{ location.city.name }}, {{ location.city.state }}, {{ location.zip }}
+ {{ location.phone }} + +
{% for row in location.hours_rows() %}
{{ row.day }}
{{ row.text }}
{% endfor %}
+
+
Map unavailable in mirror
+
+ {% endfor %} +
+
+ +
+

Ratings & Reviews for {{ doctor.short_name }}

+
+
+
+

{{ doctor.short_name }}'s Rating

+
{{ doctor.avg_rating | rating1 }} {% with rating=doctor.avg_rating %}{% include "_stars.html" %}{% endwith %}
+ + {% if doctor.avg_wait_minutes %}
Average Wait Time {{ doctor.avg_wait_minutes }} Minutes
{% endif %} +
+ +
+ {% if doctor.ratings_count %} +

Patients' Perspective

+
+ {% for row in doctor.perspectives %} +
+
{{ row.label }}
+
+
Did Well ({{ row.did_well }})Needs Improvement ({{ row.needs_improvement }})
+
+ {% endfor %} +
+ {% endif %} +
Why you can trust the reviews on WebMD Care? Reviews guidelines
+ + {% if user_review %} +
Your review {{ user_review.status }}
{% with rating=user_review.rating, small=true %}{% include "_stars.html" %}{% endwith %} "{{ user_review.text }}"
+ {% endif %} + +
+ Leave A Review + {% if current_user.is_authenticated %} +
+ +
+ +
+ {% for n in (1, 2, 3, 4, 5) %}{% endfor %} +
+
+
+ +
+ {% for label in perspective_criteria %} + {% set index = loop.index %} +
{{ label }}
+ {% endfor %} +
+
+
+ + +
+
+
+ {% else %} +

Please log in to leave a review for {{ doctor.short_name }}.

+ {% endif %} +
+ +

{{ doctor.text_review_count }} REVIEWS

Most Recent
+
Showing {{ reviews_page.start }}-{{ reviews_page.end }} of {{ reviews_page.total }} reviews
+ {% for review in reviews_page.rows %} +
+ {% with rating=review.rating %}{% include "_stars.html" %}{% endwith %} +

"{{ review.text }}"

+
More Review Details +
{% for label, ok in review.criteria_rows() %}{{ '✓' if ok else '✗' }} {{ label }}{% endfor %}
+
Wait time: {{ review.wait_bucket }}
+
+
{{ review.date_label }}
+
Helpful{% if review.helpful_count %} ({{ review.helpful_count }}){% endif %}Flag
+
+ {% else %} +

No written reviews yet.

+ {% endfor %} + {% with page=reviews_page %}{% include "_pagination.html" %}{% endwith %} +
+
+ +
+

Conditions Treated by {{ doctor.full_name }}

+
+

We verify which condition {{ doctor.display_name }} treats and which conditions they treat most often.

+ {% if doctor.is_enhanced %} +

{{ doctor.display_name }}'s Most-Treated Conditions:
Rankings are compared to the national average for similar providers.

+ {% for row in top_conditions %} +
+
{{ doctor.display_name }} treats {{ row.condition.name }} {{ {'Similar': 'as often as other providers', 'More Often': 'more often than other providers', 'More Than Most': 'more than most providers'}[row.tier] }}
+
+
SimilarMore OftenMore Than Most
+
+ {% endfor %} + {% endif %} +
View Top 20 Conditions treated by {{ doctor.display_name }} +
    {% for row in (more_conditions if doctor.is_enhanced else doctor.conditions) %}
  1. {{ row.condition.name }}
  2. {% endfor %}
+
+
+
+ +
+

Procedures Performed by {{ doctor.full_name }}

+
+

We verify which procedures {{ doctor.display_name }} performs and which procedures they perform most often.

+ {% if doctor.is_enhanced %} +

{{ doctor.display_name }}'s Most-Performed Procedures:

+ {% for row in top_procedures %} +
+
{{ doctor.display_name }} performs {{ row.procedure.name }} {{ {'Similar': 'as often as other providers', 'More Often': 'more often than other providers', 'More Than Most': 'more than most providers'}[row.tier] }}
+
+
SimilarMore OftenMore Than Most
+
+ {% endfor %} + {% endif %} +
View Top 20 Procedures performed by {{ doctor.display_name }} +
    {% for row in (more_procedures if doctor.is_enhanced else doctor.procedures) %}
  1. {{ row.procedure.name }}
  2. {% endfor %}
+
+
+
+ +
+

Areas of Expertise

+
    {% for row in doctor.expertise %}
  • {{ row.area.name }}
  • {% endfor %}
+
+ + {% if doctor.ratings_count %} +
+

Patients' Perspective

+
    {% for label in doctor.perspective_summary() %}
  • {{ label }}
  • {% endfor %}

Based on patient feedback. Read the reviews

+
+ {% endif %} + + {% if doctor.awards %} +
+

Awards

+
+ {% for award in doctor.awards %} +
+
WebMD {{ award.award_class | upper }} CHOICE
+

{{ award_classes[award.award_class | lower][2] | upper }}

Recognizes providers committed to transparency, responsiveness and great service to patients who have earned a high patient satisfaction rating. Awarded in the {{ award.year }} cycle.

Click for our Methodology
+
+ {% endfor %} +
+
+ {% endif %} + +
+

Patient Satisfaction Poll

+
+ {% for row in poll_rows %} +
{{ row.question }}
YesNo
Yes ({{ row.yes }})No ({{ row.no }})
+ {% endfor %} +
+
+ +
+

Specialties

+
    {% for name in doctor.specialty_names %}
  • {{ name }}
  • {% endfor %}
+
+ +
+

Certifications, License, & Education

+
+

{{ doctor.full_name }} holds an active medical license in the state of {{ doctor.licenses[0].state }}. They are board certified in {{ doctor.certifications | map(attribute='cert_type') | join(' and ') }} by the {{ doctor.certifications[0].issuer }}.

+

Board Certifications

+ {% for cert in doctor.certifications %}

Board certified in {{ cert.cert_type }} by the {{ cert.issuer }} in {{ cert.year }}.

{% endfor %} +

Medical License

+

{% for lic in doctor.licenses %}{{ lic.license_type }} with an {{ lic.status | lower }} medical license in the state of {{ lic.state }} that expires on {{ lic.expiry_date.strftime('%B') }} {{ lic.expiry_date.day }}, {{ lic.expiry_date.year }}{{ ' and ' if not loop.last else '.' }}{% endfor %}

+

Education & Training

+ {% if fellowships %}
FELLOWSHIP

Completed their fellowship at {% for row in fellowships %}{{ row.institution }} in {{ row.year }}{{ ' and ' if not loop.last else '.' }}{% endfor %}

{% endif %} + {% if residencies %}
RESIDENCY

Completed their residency at {% for row in residencies %}{{ row.institution }} in {{ row.year }}{{ ' and ' if not loop.last else '.' }}{% endfor %}

{% endif %} +
MEDICAL SCHOOL
+

Graduated from {{ doctor.medical_school }} in {{ doctor.graduation_year }}.

+
+
+ +
+

NPI Number

+

{{ doctor.short_name }}'s NPI number is {{ doctor.npi }}.

+
+ +
+

Languages Spoken

+
    {% for language in doctor.language_names %}
  • {{ language }}
  • {% endfor %}
+
+ +
+

Does {{ doctor.full_name }} Accept Your Insurance?

+
+

Please verify insurance information with the provider's office as it may change frequently.

+
    {% for row in doctor.insurances %}
  • {{ row.plan.label }}{% if not row.is_verified %} (unverified){% endif %}
  • {% endfor %}
+
+
+ +
+

Frequently Asked Questions

+
+
+
What conditions does {{ doctor.full_name }} treat?
+
{{ doctor.full_name }} most often treats {{ doctor.conditions[:3] | map(attribute='condition') | map(attribute='name') | join(', ') }}.
+
What treatments does {{ doctor.full_name }} specialize in?
+
{{ doctor.full_name }} performs the following procedures most often: {{ doctor.procedures[:3] | map(attribute='procedure') | map(attribute='name') | join(', ') }}.
+
What insurance plans does {{ doctor.full_name }} accept?
+
{{ doctor.full_name }} accepts a range of insurance plans, including {{ insurer_names[:4] | join(', ') }}. Check the Insurance section above to verify your plan.
+
Is {{ doctor.full_name }} accepting new patients?
+
{% if doctor.accepting_new_patients %}Yes, {{ doctor.full_name }} is currently accepting new patients.{% else %}No, {{ doctor.full_name }} is not currently listed as accepting new patients.{% endif %} Contact the provider via this profile to book an appointment.
+
Where did {{ doctor.full_name }} go to medical school?
+
{{ doctor.full_name }} earned {{ doctor.possessive }} medical degree ({{ doctor.degree }}) from {{ doctor.medical_school }} in {{ doctor.graduation_year }}.
+
What languages does {{ doctor.full_name }} speak?
+
{{ doctor.full_name }} speaks {{ doctor.language_names | join(' and ') }}.
+
+
+
+ +
+

Other {{ doctor.primary_specialty.plural }} Nearby

+
+
+ +
+

Data Transparency and Trust: Understanding WebMD Doctor Listings and Reviews

+
+

We know that finding the right doctor or provider is important to your health. That's why we want to ensure you have confidence in the provider profiles and listings you see on WebMD Care. Provider data in this mirror is synthetic benchmark data; on the live site it is sourced from physicians themselves and publicly available databases.

+

All the physician and provider reviews on WebMD Care are provided by users just like you. Providers are not able to remove or modify reviews on their own.

+
+
+
+ + +
+
+{% endblock %} diff --git a/sites/webmd_doctor/templates/guidelines.html b/sites/webmd_doctor/templates/guidelines.html new file mode 100644 index 00000000..4754cec8 --- /dev/null +++ b/sites/webmd_doctor/templates/guidelines.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Reviews Guidelines{% endblock %} +{% block content %} +
+
+

Reviews Guidelines

+

Reviews on WebMD Care are written by patients and shared to help other patients choose a provider. To keep them useful and fair, every review submitted on this site follows the rules below. Reviews that do not follow them are held for moderation and may be removed.

+

What to include

+
    +
  • Your own experience with the provider: how the visit went, whether your questions were answered, wait time and follow-up.
  • +
  • An overall star rating from 1 to 5, and a "did well" or "needs improvement" answer for each of the seven Patients' Perspective criteria.
  • +
  • At least 20 characters of written feedback so other patients understand the rating.
  • +
+

What to leave out

+
    +
  • Personal health information about other people, or the full names of office staff.
  • +
  • Profanity, threats, discriminatory language or advertising.
  • +
  • Reviews of a provider you have not seen, or reviews written on behalf of a provider.
  • +
+

How reviews are handled

+

A newly submitted review appears on the provider's profile to its author with the status Pending review until it is checked by the moderation team. Published ratings and counts on a profile do not change until a review is approved. Providers cannot edit or remove reviews; they may reply to a published review through their claimed profile.

+

Why you can trust the reviews on WebMD Care

+

Every review is tied to a registered account, screened for the rules above, and displayed with its submission date. Helpful votes and flags from other readers are used to prioritise moderation.

+
+
+{% endblock %} diff --git a/sites/webmd_doctor/templates/hospital.html b/sites/webmd_doctor/templates/hospital.html new file mode 100644 index 00000000..22b2c4a7 --- /dev/null +++ b/sites/webmd_doctor/templates/hospital.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}{{ hospital.name }} - {{ hospital.city.name }}, {{ hospital.city.state }}{% endblock %} +{% block content %} +
+ +

{{ hospital.name }}

+
+
{{ specialty_rows | length | plural_word('Specialty', 'Specialties') }}{{ doctors | length | plural_word('Practicing Physician') }}
+
{% with rating=hospital.avg_rating, small=true %}{% include "_stars.html" %}{% endwith %} ({{ hospital.ratings_count }}) | Write A Review
+
{{ hospital.street }} {{ hospital.city.name }}, {{ hospital.city.state }} {{ hospital.zip }}
+ {{ hospital.phone }} +
+
+
+

Overview

{{ hospital.overview_text }}

+
+

Physicians At {{ hospital.name }}

+
+
Showing {{ page.start }}-{{ page.end }} of {{ page.total }} Physicians
+
+ {% for doctor in page.rows %} +
+ +
+ +
{{ doctor.primary_specialty.name }}
+
{% with rating=doctor.avg_rating, small=true %}{% include "_stars.html" %}{% endwith %} ({{ doctor.ratings_count }})
+ {% if doctor.accepting_new_patients %}
Accepting New Patients
{% endif %} +
+
+ {% endfor %} +
+ {% include "_pagination.html" %} +
+
+

Specialties

{{ doctors | length }} practicing physicians across {{ specialty_rows | length }} specialties are affiliated with this hospital.

    {% for name, count in specialty_rows %}
  • {{ name }} ({{ count }})
  • {% endfor %}
+

Locations

{{ hospital.name }}
{{ hospital.street }}
{{ hospital.city.name }}, {{ hospital.city.state }} {{ hospital.zip }}
Map unavailable in mirror
+

Ratings And Reviews

{{ hospital.name }} Rating
{% with rating=hospital.avg_rating %}{% include "_stars.html" %}{% endwith %} {{ hospital.ratings_count | plural_word('Rating') }}
Reviews are written on provider profiles
+

Patient Satisfaction Poll

{% for row in poll_rows %}
{{ row.question }}
YesNo
Yes ({{ row.yes }})No ({{ row.no }})
{% endfor %}
+

Frequently Asked Questions

+
Where is {{ hospital.name }} located?
{{ hospital.name }} is located at {{ hospital.street }}, {{ hospital.city.name }}, {{ hospital.city.state }}, {{ hospital.zip }}.
+
What is {{ hospital.name }}'s phone number?
The contact number for {{ hospital.name }} is {{ hospital.phone }}.
+
Has {{ hospital.name }} won any recent awards from WebMD?
{% if award_count %}{{ award_count | plural_word('physician award') }} have been earned by providers affiliated with {{ hospital.name }}.{% else %}No, {{ hospital.name }} has not received any WebMD Choice awards.{% endif %}
+
What specialties are available at {{ hospital.name }}?
There are {{ doctors | length }} practicing providers across {{ specialty_rows | length }} specialties working at {{ hospital.name }}. Top specialties include {{ top_specialties | join(', ') }}.
+
How do I schedule an initial appointment at {{ hospital.name }}?
You can call {{ hospital.phone }} to schedule an appointment at {{ hospital.name }} or you can call any of the providers listed under "Physicians At This Hospital".
+
+
+ +
+
+{% endblock %} diff --git a/sites/webmd_doctor/templates/hub_list.html b/sites/webmd_doctor/templates/hub_list.html new file mode 100644 index 00000000..edc10a08 --- /dev/null +++ b/sites/webmd_doctor/templates/hub_list.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}{{ title }}{% if state_name %} in {{ state_name }}{% endif %}{% endblock %} +{% block content %} +
+ +

{{ title }}{% if state_name %} in {{ state_name }}{% endif %}

+

There are {{ total }} {{ title }} for you to review across {{ city_count }} cities and towns.

+ {% if not state_name %} +
+ {% for state in states %}{{ state.name }} ({{ state.count }}){% endfor %} +
+ {% endif %} +
+
+
+ +
+ {% for key, label in hub_sort_options %}{% endfor %} +
+
+
+ +
+ + {% for n in (5, 4, 3, 2, 1) %}{% endfor %} +
+
+
+
+
+ +
+ {% for item in page.rows %} + {% set detail = url_for('hospital_detail', slug=item.slug) if kind == 'hospitals' else url_for('practice_detail', slug=item.slug) %} +
+
+

{{ item.name }}

+
{{ counts[item.id].specialties | plural_word('Specialty', 'Specialties') }}, {{ counts[item.id].physicians | plural_word('Practicing Physician') }}
+
{% with rating=item.avg_rating, small=true %}{% include "_stars.html" %}{% endwith %} ({{ item.ratings_count | plural_word('Rating') }})
+
{{ item.street }}, {{ item.city.name }}, {{ item.city.state }} {{ item.zip }}
+
{{ item.overview_text }}
+
+ +
+ {% else %} +
No {{ title | lower }} matched these filters.
+ {% endfor %} +
+ {% include "_pagination.html" %} +
+{% endblock %} diff --git a/sites/webmd_doctor/templates/index.html b/sites/webmd_doctor/templates/index.html new file mode 100644 index 00000000..d410308c --- /dev/null +++ b/sites/webmd_doctor/templates/index.html @@ -0,0 +1,91 @@ +{% extends "base.html" %} +{% set hide_search_row = true %} +{% block body_class %}home{% endblock %} +{% block content %} +
+
+
8 million+ Physician Ratings & Reviews
+

Find Doctors and Dentists Near You

+ {% with search_bar_id='hero' %}{% include "_search_bar.html" %}{% endwith %} +
+ {% for label, href in preset_chips %}{{ label }}{% endfor %} +
+
+
+
+
+
+

Choose the healthcare
that is right for you

+
    +
  • Profiles for 3 million+ physicians
  • +
  • Book appointments online including Video Visit and Chat Now options
  • +
  • Find award winning hospitals by specialty
  • +
+
+
+ +
Doctors Rating
{% with rating=5.0, small=true %}{% include "_stars.html" %}{% endwith %} 5.0
16 Reviews

Explains conditions and treatments
+
  • 14 Years Experience
  • Accepts Medicare
  • Speaks Spanish
  • Book Today
+
+
+
+
+
+

Popular specialties

+
+ {% for specialty in specialties %}{{ specialty.singular }}{% endfor %} +
+
+
+
+
+
Top Doctors Near
+
Newark, DE
+
+ {% for doctor in top_doctors %}{% include "_mini_card.html" %}{% endfor %} +
+

FIND YOUR DOCTOR

+

Physicians: Claim Your Profile ›

+
+
+
+
+ +
+

WebMD Choice Awards

+

Power to the Patients

+

You need information that you can trust when seeking care. The WebMD Choice Awards is the only hospital recognition program based on the opinion of patients and health care providers. Because we believe when patients have a voice, everybody wins.

+ SEE HOSPITAL RANKINGS +
+
+
+
+
+

Healthcare specialists for everyone everywhere

+
BY SPECIALTY
+
+ {% for specialty in specialties %}{{ specialty.name }}{% endfor %} +
+

View all specialties ›

+
+
+
+
+
8 million+ Physician Ratings & Reviews
+

Find Doctors and Dentists Near You

+ {% with search_bar_id='bottom' %}{% include "_search_bar.html" %}{% endwith %} +
+ {% for label, href in preset_chips %}{{ label }}{% endfor %} +
+
+
+ + +{% endblock %} diff --git a/sites/webmd_doctor/templates/login.html b/sites/webmd_doctor/templates/login.html new file mode 100644 index 00000000..0f28c5c7 --- /dev/null +++ b/sites/webmd_doctor/templates/login.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% set hide_search_row = true %} +{% block title %}Log In{% endblock %} +{% block content %} +
+
+ × +

Care that starts with a conversation

Save providers, request appointments and share your experience with other patients.

+
+
Don't have an account? Sign Up

Log In

+ {% if errors %}
    {% for error in errors %}
  • {{ error }}
  • {% endfor %}
{% endif %} +
+ + {% if next_url %}{% endif %} + + + + + Forgot Password? + + +
+
+
+
+{% endblock %} diff --git a/sites/webmd_doctor/templates/practice.html b/sites/webmd_doctor/templates/practice.html new file mode 100644 index 00000000..6e1ddca1 --- /dev/null +++ b/sites/webmd_doctor/templates/practice.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% block title %}{{ practice.name }} - {{ practice.city.name }}, {{ practice.city.state }}{% endblock %} +{% block content %} +
+ +

{{ practice.name }} Claim your practice

+
+
{{ specialty_rows | length | plural_word('Specialty', 'Specialties') }}{{ doctors | length | plural_word('Practicing Physician') }}
+
{% with rating=practice.avg_rating, small=true %}{% include "_stars.html" %}{% endwith %} ({{ practice.ratings_count }}) | Write A Review
+
{{ practice.name }}
{{ practice.street }} {{ practice.city.name }}, {{ practice.city.state }} {{ practice.zip }}
+ {{ practice.phone }} +
+
+
+

Overview

{{ practice.overview_text }}

+
+

Physicians At {{ practice.name }}

+
+
Showing {{ page.start }}-{{ page.end }} of {{ page.total }} Physicians
+
+ {% for doctor in page.rows %} +
+ +
+ +
{{ doctor.primary_specialty.name }}
+
{% with rating=doctor.avg_rating, small=true %}{% include "_stars.html" %}{% endwith %} ({{ doctor.ratings_count }})
+ {% if doctor.accepting_new_patients %}
Accepting New Patients
{% endif %} +
+
+ {% endfor %} +
+ {% include "_pagination.html" %} +
+
+

Patient Satisfaction Poll

{% for row in poll_rows %}
{{ row.question }}
YesNo
Yes ({{ row.yes }})No ({{ row.no }})
{% endfor %}
+

Specialties

{{ doctors | length }} practicing physicians across {{ specialty_rows | length }} specialties are affiliated with this practice.

    {% for name, count in specialty_rows %}
  • {{ name }} ({{ count }})
  • {% endfor %}
+

Insurance Plans Accepted ({{ insurers | length }})

Please verify insurance information with your doctor's office as it may change frequently.

    {% for name in insurers %}
  • {{ name }}
  • {% endfor %}
+

Locations

+
+
{{ practice.name }}
+
{{ practice.street }}
{{ practice.city.name }}, {{ practice.city.state }} {{ practice.zip }}
+ + +
+ {{ 'Accepts' if flags.medicare else 'Does not accept' }} Medicare + {{ 'Accepts' if flags.medicaid else 'Does not accept' }} Medicaid + {{ 'Accepting' if flags.new_patients else 'Not accepting' }} new patients +
+
{% for row in practice.hours_rows() %}
{{ row.day }}
{{ row.text }}
{% endfor %}
+
Map unavailable in mirror
+
+

Frequently Asked Questions

+
Where is {{ practice.name }} located?
{{ practice.name }} is located at {{ practice.street }}, {{ practice.city.name }}, {{ practice.city.state }}, {{ practice.zip }}.
+
What is {{ practice.name }}'s phone number?
The contact number for {{ practice.name }} is {{ practice.phone }}.
+
What specialties are available at {{ practice.name }}?
There are {{ doctors | length }} practicing providers across {{ specialty_rows | length }} specialties at {{ practice.name }}. Top specialties include {{ top_specialties | join(', ') }}.
+
Does {{ practice.name }} accept my insurance plan?
Providers at {{ practice.name }} list {{ insurers | length }} insurance carriers, including {{ insurers[:3] | join(', ') }}. To verify, call {{ practice.phone }}.
+
+
+ +
+
+{% endblock %} diff --git a/sites/webmd_doctor/templates/results.html b/sites/webmd_doctor/templates/results.html new file mode 100644 index 00000000..6e3acf3c --- /dev/null +++ b/sites/webmd_doctor/templates/results.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}{{ term }} near {{ heading_place }}{% endblock %} +{% block content %} +
+
+

{{ term }} near {{ heading_place }}

+
{{ page.total | plural_word('Result') }}
+
+ {% if loc.fallback %}
Showing providers near Newark, DE 19711 — we couldn't match '{{ loc.input }}'.
{% endif %} + {% include "_filter_bar.html" %} +
WebMD PATIENT'S CHOICEPatients' Choice awards are assigned based on patient satisfaction ratings for key specialties in select geographic locations.
+ + {% if page.rows %} +
+ {% for row in page.rows %}{% with doctor=row.doctor, distance=row.distance %}{% include "_physician_card.html" %}{% endwith %}{% endfor %} +
+ {% include "_pagination.html" %} + {% else %} +
No providers matched these filters near {{ heading_place }}. Try widening the distance or clearing a filter.
+ {% endif %} +
+{% endblock %} diff --git a/sites/webmd_doctor/templates/signup.html b/sites/webmd_doctor/templates/signup.html new file mode 100644 index 00000000..85a62c1d --- /dev/null +++ b/sites/webmd_doctor/templates/signup.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% set hide_search_row = true %} +{% block title %}Sign Up{% endblock %} +{% block content %} +
+
+ × +

Join WebMD Care

Create a free account to save providers and request appointments.

+
+
Already have an account? Log In

Sign Up

+ {% if errors %}
    {% for error in errors %}
  • {{ error }}
  • {% endfor %}
{% endif %} +
+ + {% if next_url %}{% endif %} + + + + + + + +
+
+
+
+{% endblock %} diff --git a/sites/webmd_doctor/templates/specialty_city.html b/sites/webmd_doctor/templates/specialty_city.html new file mode 100644 index 00000000..dd3f506d --- /dev/null +++ b/sites/webmd_doctor/templates/specialty_city.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block title %}Best {{ specialty.plural }} in {{ city.name }}, {{ city.state }}{% endblock %} +{% block content %} +
+ +

Best {{ specialty.plural }} in {{ city.name }}, {{ city.state }}

+

{{ city.name }}, {{ city.state }} has {{ total }} {{ specialty.singular }} results with an average of {{ average_experience }} years of experience and a total of {{ total_reviews }} ratings. Currently, {{ accepting }} providers have noted they are accepting new patients. Conditions treated by {{ specialty.plural }} often include {{ conditions | map(attribute='name') | join(', ') }}. Some common procedures performed by {{ specialty.plural }} include {{ procedures | map(attribute='name') | join(', ') }}.

+ {% include "_filter_bar.html" %} +
WebMD PATIENT'S CHOICEPatients' Choice awards are assigned based on patient satisfaction ratings for key specialties in select geographic locations.
+ + {% if page.rows %} +
+ {% for row in page.rows %}{% with doctor=row.doctor %}{% include "_physician_card.html" %}{% endwith %}{% endfor %} +
+ {% include "_pagination.html" %} + {% else %} +
No {{ specialty.plural }} in {{ city.name }} matched these filters.
+ {% endif %} +
+{% endblock %} diff --git a/sites/webmd_doctor/templates/specialty_index.html b/sites/webmd_doctor/templates/specialty_index.html new file mode 100644 index 00000000..41b9d620 --- /dev/null +++ b/sites/webmd_doctor/templates/specialty_index.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block title %}Find Top Doctors for All Specialties{% endblock %} +{% block content %} +
+ +
+
+

Find Top Doctors for All Specialties

+ {% set letters = specialties | map(attribute='name') | map('first') | list %} +
All{% for letter in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' %}{% if letter in letters %}{{ letter }}{% else %}{{ letter }}{% endif %}{% endfor %}
+
+ {% for specialty in specialties %}{{ specialty.name }}{% endfor %} +
+
+
+{% endblock %} diff --git a/sites/webmd_doctor/templates/specialty_landing.html b/sites/webmd_doctor/templates/specialty_landing.html new file mode 100644 index 00000000..670fcf3e --- /dev/null +++ b/sites/webmd_doctor/templates/specialty_landing.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% block title %}Best {{ specialty.plural }} in the United States{% endblock %} +{% block content %} +
+ +

Best {{ specialty.plural }} in the United States

+

There are {{ total }} {{ specialty.plural }} for you to review across {{ states | length }} states. {{ specialty.description }}

+
+ {% for state in states %}{{ state.name }} ({{ state.count }}){% endfor %} +
+

Highest Rated {{ specialty.plural }} near {{ anchor.name }}, {{ anchor.state }}

+
+ {% for doctor in highest_rated %}{% include "_mini_card.html" %}{% endfor %} +
+

Search All {{ specialty.plural }} in {{ anchor.name }}, {{ anchor.state }}

+

{{ specialty.plural }} on WebMD Care

+

{{ specialty.plural }} listed here have an average rating of {{ average_rating | rating1 }} stars and {{ total_ratings }} patient ratings between them.

+
+
{{ average_rating | rating1 }} average
rating
These ratings help you find the best doctor that suits your specific medical needs.
+
{{ total }} across
{{ city_chips | length }} cities
There are {{ total }} {{ specialty.plural }} across the {{ city_chips | length }} cities covered by WebMD Care.
+
+
+

Frequently Asked Questions

+
+
+
What does a {{ specialty.singular | lower }} treat?
+
{{ specialty.description }} Common conditions include {{ conditions | map(attribute='name') | join(', ') }}.
+
What procedures do {{ specialty.plural | lower }} perform?
+
{{ specialty.plural }} on WebMD Care most often list {{ procedures | map(attribute='name') | join(', ') }}.
+
How are {{ specialty.plural | lower }} certified?
+
Board certification for {{ specialty.name }} is issued by the {{ specialty.board_name }}. Each profile's Certifications, License & Education section lists the certifying board and year.
+
+
+
+
+

Find {{ specialty.plural }} by City

+
+ {% for city in city_chips %}{{ city.name }}, {{ city.state }} ({{ city.count }}){% endfor %} +
+
+
+{% endblock %} diff --git a/sites/webmd_doctor/templates/specialty_state.html b/sites/webmd_doctor/templates/specialty_state.html new file mode 100644 index 00000000..75aaa382 --- /dev/null +++ b/sites/webmd_doctor/templates/specialty_state.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}Best {{ specialty.plural }} in {{ state_name }}{% endblock %} +{% block content %} +
+ +

Best {{ specialty.plural }} in {{ state_name }}

+

{{ state_name }} has {{ total }} {{ specialty.singular }} results across {{ cities | length }} cities. Choose a city or filter the full list below.

+
+ {% for city in cities %}{{ city.name }} ({{ city.count }}){% endfor %} +
+ {% include "_filter_bar.html" %} + +
+ {% for row in page.rows %}{% with doctor=row.doctor %}{% include "_physician_card.html" %}{% endwith %}{% endfor %} +
+ {% include "_pagination.html" %} +
+{% endblock %} diff --git a/websyn_start.sh b/websyn_start.sh index 9539b4f9..2a4bf43d 100644 --- a/websyn_start.sh +++ b/websyn_start.sh @@ -5,7 +5,8 @@ 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 target ted osu rotten_tomatoes compass walmart_careers) + cambridge_dictionary coursera espn merriam_webster ikea phys_org target ted osu rotten_tomatoes compass walmart_careers + webmd_doctor) BASE_PORT=40000 PID_DIR=/tmp/websyn_pids mkdir -p "$PID_DIR" From c88d2ea3c939e2045b3cc279eb7b1a6b8216d649 Mon Sep 17 00:00:00 2001 From: evanz37 <89922710+evanz37@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:57:53 -0400 Subject: [PATCH 02/21] feat(webmd_doctor): add 20 benchmark tasks Contributor-side task definitions only (web_name, id, ques, web, upstream_url): 9 lookups (0-7, 15), 4 multi-constraint (9-12), 2 compares (13-14), 5 stateful (8, 16-19). Every ques routes through a specialty + city, a hub page or an awards class before naming a doctor (name search is a non-goal, as upstream); credentials are embedded for login tasks; no answers, verifiers or rubrics. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GTvmqJcShyy3v3KfEPxMPe --- sites/webmd_doctor/tasks.jsonl | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 sites/webmd_doctor/tasks.jsonl diff --git a/sites/webmd_doctor/tasks.jsonl b/sites/webmd_doctor/tasks.jsonl new file mode 100644 index 00000000..7207749a --- /dev/null +++ b/sites/webmd_doctor/tasks.jsonl @@ -0,0 +1,20 @@ +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--0", "ques": "Search for Dermatologists near Newark, DE 19711 and open the profile of Dr. Jonah Dimitriou. Report the medical school Dr. Dimitriou graduated from and the graduation year.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Dermatologist&sids=29244"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--1", "ques": "Find Dr. Julian Zamora, a Cardiovascular Disease specialist whose primary office is in Wilmington, DE. Report the NPI number shown on the profile and the languages spoken.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Cardiologist&sids=29236"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--2", "ques": "Search for Family Medicine doctors near Newark, DE 19711 and open Dr. Ruth Thackeray's profile. Report the phone number listed for the primary office and that office's Saturday hours.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Family+Medicine"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--3", "ques": "Open the profile of Dr. Mateo Alvarado, a Neurologist in West Chester, PA. Besides the primary office, the Locations section lists one other office. Report that office's name and street address.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/neurology/pennsylvania/west-chester"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--4", "ques": "Find Dr. Naomi Merriweather, an Orthopedic Surgeon in Elkton, MD. From the Certifications, License & Education section, report the board that certified them, the certification year, and the institution where they completed their residency.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/orthopedic-surgery/maryland/elkton"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--5", "ques": "Search for Gastroenterologists near Newark, DE 19711 and open Dr. Caroline Danforth's profile. Among the five most-treated conditions shown, exactly one is marked \"More Than Most\". Which condition is it, and which condition is listed first under \"View Top 20\"?", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Gastroenterologist"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--6", "ques": "Open the profile of Dr. Fatima Jensen, a Psychiatrist in Media, PA, and read all of their reviews. What is the date shown on the oldest review, and what star rating did that reviewer give?", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/psychiatry/pennsylvania/media"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--7", "ques": "Find Dr. Lillian Acosta, an Obstetrics & Gynecology specialist in Salem, NJ. Which of the seven Patients' Perspective criteria received the most needs-improvement votes, and what average wait time is shown on the profile?", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/obstetrics-gynecology/new-jersey/salem"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--8", "ques": "Log in as alice.j@test.com (password: TestPass123!) and open Saved Providers. Exactly one of your saved providers is a Dermatologist. Open that profile and report the institution where they completed their residency, then remove that provider from your saved list.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--9", "ques": "Search for Dermatologists near Newark, DE 19711 who are female, accept new patients and accept Blue Cross Blue Shield. Among the results, open the profile of the doctor with fewer than 5 years of experience and report their medical school and the year of their board certification.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Dermatologist&sids=29244"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--10", "ques": "Find Psychiatrists near Newark, DE 19711 who accept Medicaid and have a rating of 4 stars or higher. Open the profile of the one who offers virtual visits and report the average wait time and the residency institution listed.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Psychiatrist"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--11", "ques": "Set the distance to 10 miles from Newark, DE 19711, search for Family Medicine doctors and sort by Number of Ratings. Open the profile of the doctor with the second-highest number of ratings and report their NPI number and the hospital they are affiliated with.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Family+Medicine"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--12", "ques": "From the Find Providers by Specialty menu open Cardiovascular Disease, then Pennsylvania, then West Chester. Filter to doctors rated 4 stars or higher. Open the profile of the only male doctor in that list and report his fellowship institution and the year he completed it.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/cardiovascular-disease/pennsylvania/west-chester"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--13", "ques": "Among Dermatologists in Wilmington, DE, Dr. Gregory Greenwood and Dr. Dana Valdez both accept Blue Cross Blue Shield. Which of the two graduated from medical school earlier? Report that doctor's name and graduation year.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/dermatology/delaware/wilmington"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--14", "ques": "Open the hospital page for White Clay Regional Hospital (Find a Facility > Hospitals > Delaware). Two of its listed physicians are Neurologists; open both profiles. Which one was board certified more recently? Report that doctor's name and the certification year.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/hospitals/delaware"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--15", "ques": "Open the Choice Awards page and view the WebMD Patient's Choice recipients. Find the recipient who practices Orthopedic Surgery in Media, PA, open their profile, then open the practice page linked from their primary office. Report the practice's website address and its Saturday hours.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/choice-awards/awardrecipients?award-class=patient"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--16", "ques": "Log in with the demo account (email: bob.c@test.com, password: TestPass123!), search for Pediatricians near Newark, DE 19711, open the profile of Dr. Anita Castellano and save the provider. Then open Saved Providers and confirm Dr. Castellano appears there.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Pediatrician"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--17", "ques": "Log in as carol.d@test.com (password: TestPass123!). Open the profile of Dr. Sarah Keller, a Cardiovascular Disease specialist in Newark, DE, and request an appointment as a New Patient at the Riverfront Heart & Vascular - Wellness Center office on Mon, Sep 14 at 10:30 AM. Report the confirmation reference shown after submitting.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Cardiologist&sids=29236"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--18", "ques": "Log in as david.k@test.com (password: TestPass123!). Find Dr. Tariq Huang, a Dermatologist in Elkton, MD, and leave a 4-star review with the text \"Short wait and a clear explanation of my treatment options.\" Confirm the profile now shows your review as Pending review.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/reviews-guidelines"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--19", "ques": "Register a new account with an email and password of your choice. Then search for Neurologists near Newark, DE 19711 who offer virtual visits, open the profile of Dr. Monica Carrington, and save the provider. Report the NPI number shown on Dr. Carrington's profile.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Neurologist"} From 0362f463ad65408a2a8aa04128fa342c8a3c88df Mon Sep 17 00:00:00 2001 From: evanz37 <89922710+evanz37@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:06:08 -0400 Subject: [PATCH 03/21] fix(webmd_doctor): evolve mirror to support all tasks - seed: two extra West Chester cardiology slots appended after the grid (task 12 now has 8 doctors on the city page, 6 rated 4+ with exactly one male); deterministic language top-up for physicians covering three offices (task 1 target speaks 3 languages); EXPECTED_COUNTS refreshed (226 doctors, 202 in radius) - pagination: disabled prev/next rendered as spans instead of live links to an empty page - filter bar: empty and default params are dropped from auto-submitted URLs Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GTvmqJcShyy3v3KfEPxMPe --- sites/webmd_doctor/seed_data.py | 59 ++++++++++++++----- sites/webmd_doctor/static/js/site.js | 23 +++++++- sites/webmd_doctor/templates/_pagination.html | 4 +- 3 files changed, 67 insertions(+), 19 deletions(-) diff --git a/sites/webmd_doctor/seed_data.py b/sites/webmd_doctor/seed_data.py index 987eb6a9..4fa2748b 100644 --- a/sites/webmd_doctor/seed_data.py +++ b/sites/webmd_doctor/seed_data.py @@ -95,19 +95,19 @@ "city_zips": 24, "hospitals": 12, "practices": 30, - "doctors": 224, - "locations": 345, - "doctor_conditions": 1667, - "doctor_procedures": 1241, - "doctor_expertise": 681, - "doctor_insurances": 2244, - "reviews": 1195, - "doctor_perspectives": 1568, - "certifications": 293, - "licenses": 314, - "education": 562, + "doctors": 226, + "locations": 348, + "doctor_conditions": 1677, + "doctor_procedures": 1252, + "doctor_expertise": 686, + "doctor_insurances": 2274, + "reviews": 1206, + "doctor_perspectives": 1582, + "certifications": 296, + "licenses": 316, + "education": 567, "awards": 50, - "doctor_languages": 310, + "doctor_languages": 351, "users": 4, "saved_providers": 4, "appointment_requests": 1, @@ -246,6 +246,14 @@ "Dermatology": 3, "Cardiovascular Disease": 2, "Family Medicine": 3, "Neurology": 2, "Orthopedic Surgery": 2, "Gastroenterology": 2, "Psychiatry": 3, "Obstetrics & Gynecology": 2, "Pediatrics": 3, "Internal Medicine": 2, } +# Cells that need a deeper bench of highly rated physicians than the 20-per-specialty grid gives +# them (appended after the grid so the grid's own slot order is untouched). +CLUSTER_EXTRAS = [ + {"specialty": "Cardiovascular Disease", "city": "West Chester", "gender": "f", "tier": "Basic", + "rating": 4.2, "years": 17, "new_patients": True, "virtual": False, "medicare": True, "medicaid": False}, + {"specialty": "Cardiovascular Disease", "city": "West Chester", "gender": "n", "tier": "Enhanced", + "rating": 4.1, "years": 9, "new_patients": True, "virtual": True, "medicare": True, "medicaid": True}, +] # name, city, street, zip, phone suffix, website slug HOSPITALS = [ ("Christina Creek Medical Center", "Newark", "1200 Ogletown Stanton Rd", "19713", "555-0140", "christinacreekmed"), @@ -607,7 +615,7 @@ def _build_practices(cities: dict[str, City], used_phones: set[str]) -> dict[str def _doctor_slots() -> list[dict]: - """Deterministic list of (specialty, city, gender, tier) slots — 200 in radius + 24 Baltimore.""" + """Deterministic list of (specialty, city, gender, tier) slots — 200 in radius + 24 Baltimore + cluster extras.""" slots: list[dict] = [] for spec_name, *_rest in SPECIALTIES: cells = CLUSTERS[spec_name] @@ -626,13 +634,15 @@ def _doctor_slots() -> list[dict]: for _ in range(BALTIMORE_PER_SPECIALTY[spec_name]): slots.append({"specialty": spec_name, "city": "Baltimore", "gender": baltimore_genders[cursor], "tier": baltimore_tiers[cursor]}) cursor += 1 - assert len(slots) == 224 + for extra in CLUSTER_EXTRAS: + slots.append(dict(extra)) + assert len(slots) == 224 + len(CLUSTER_EXTRAS) return slots def _assign_quotas(slots: list[dict]) -> None: """Attach the quota-controlled attributes to each slot (in-radius multisets first).""" - in_radius = [slot for slot in slots if slot["city"] != "Baltimore"] + in_radius = [slot for slot in slots if slot["city"] != "Baltimore" and "rating" not in slot] baltimore = [slot for slot in slots if slot["city"] == "Baltimore"] ratings = multiset([(5.0, 24)] + [(None, 6)]) ratings += multiset([(round(4.0 + 0.1 * i, 1), 9) for i in range(10)]) @@ -1101,6 +1111,24 @@ def _finish_hubs(hospital_rows: list[Hospital], practice_rows: list[Practice]) - # --------------------------------------------------------------------------- # # Seed entry points (whole-function gates) # --------------------------------------------------------------------------- # +def _topup_languages(doctors: list[Doctor]) -> None: + """Physicians who cover three offices are seeded as multilingual (English + two more). + Deterministic in the doctor id — consumes no RNG, so it can run after every other builder.""" + for doctor in doctors: + if len(doctor.locations) < 3: + continue + spoken = {row.language for row in doctor.languages} + position = len(doctor.languages) + 1 + for offset in (7, 11): + language = LANGUAGES[(doctor.id * offset + offset) % len(LANGUAGES)] + if language in spoken or position > 3: + continue + db.session.add(DoctorLanguage(doctor_id=doctor.id, language=language, position=position)) + spoken.add(language) + position += 1 + db.session.flush() + + def seed_database(force: bool = False) -> None: if Doctor.query.count() > 0 and not force: return @@ -1114,6 +1142,7 @@ def seed_database(force: bool = False) -> None: _ensure_similar_tiers() _build_awards(doctors, vocab) _finish_hubs(Hospital.query.order_by(Hospital.id).all(), Practice.query.order_by(Practice.id).all()) + _topup_languages(doctors) for doctor in doctors: del doctor._slot del doctor._practice diff --git a/sites/webmd_doctor/static/js/site.js b/sites/webmd_doctor/static/js/site.js index 1b6aa123..02efc14c 100644 --- a/sites/webmd_doctor/static/js/site.js +++ b/sites/webmd_doctor/static/js/site.js @@ -31,9 +31,24 @@ }); // Filter bar: submit as soon as a choice changes (checkbox pills, popover radios, selects). + // Empty / default fields are disabled right before submit so they stay out of the URL. + function pruneEmptyFields(form) { + Array.prototype.forEach.call(form.elements, function (field) { + if (!field.name || field.type === "submit" || field.type === "button") { return; } + if (field.type === "checkbox" || field.type === "radio") { + var isDefault = (field.name === "sortby" && field.value === "bestmatch") || (field.name === "gender" && field.value === "all"); + if (!field.checked || field.value === "" || isDefault) { field.disabled = true; } + return; + } + if (field.value === "") { field.disabled = true; } + }); + } document.querySelectorAll("form.filter-form").forEach(function (form) { + form.addEventListener("submit", function () { pruneEmptyFields(form); }); form.querySelectorAll("input[type=checkbox], input[type=radio], select").forEach(function (input) { - input.addEventListener("change", function () { form.submit(); }); + input.addEventListener("change", function () { + if (typeof form.requestSubmit === "function") { form.requestSubmit(); } else { pruneEmptyFields(form); form.submit(); } + }); }); }); @@ -72,7 +87,11 @@ hits.forEach(function (item) { var link = document.createElement("a"); link.href = item.href; - link.textContent = item.label; + if (section[0] === "SPECIALTY") { link.className = "ta-specialty"; } + var hit = document.createElement("b"); + hit.textContent = item.label.slice(0, term.length); + link.appendChild(hit); + link.appendChild(document.createTextNode(item.label.slice(term.length))); box.appendChild(link); total += 1; }); diff --git a/sites/webmd_doctor/templates/_pagination.html b/sites/webmd_doctor/templates/_pagination.html index b3e5d0d7..9bb1d7e7 100644 --- a/sites/webmd_doctor/templates/_pagination.html +++ b/sites/webmd_doctor/templates/_pagination.html @@ -1,12 +1,12 @@ {# expects `page` dict, `base_path` and a `page_qs(n)` callable returning the query string #} {% if page.pages > 1 %} {% endif %} From a3167ad88370473ee14d5c2d336a31d95b29b54e Mon Sep 17 00:00:00 2001 From: evanz37 <89922710+evanz37@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:06:08 -0400 Subject: [PATCH 04/21] fix(webmd_doctor): visual parity with reference captures Section-by-section pass against scraped_data/reference at 1440 px: - profile: booking widget overhangs the hero into the rail, tabs span the main column only, colleagues tile grid / hospital affiliation card in the stack, Basic rail below the tabs, NPI folded into the certification card, nearby + transparency panels full width, star picker, review controls, check-circle perspective icons, two-column bulleted top-20 lists - home: icon tile row for popular specialties, filled uppercase View Profile buttons, upstream heading sizes - filter pills in dark text with navy active state; typeahead no longer clipped by the search bar, prefix highlighting - hospitals / group practices hubs rebuilt as landing pages (state chips, name search, top-4 cards, search-all button, care-type chips); state lists drop the sliders button and gain a name filter - hospital / practice details: specialty select, map-on-top locations, ratings card, text flag lines - awards page art panels + larger headings; recipients grouped by state with a state filter; guidelines as a centred numbered card; sign-up terms line and mm/dd/yyyy date field; login/state/city copy per upstream - landing "Highest Rated" strip now the 25-mile ring so no task target is showcased there Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GTvmqJcShyy3v3KfEPxMPe --- sites/webmd_doctor/app.py | 99 ++++++++---- sites/webmd_doctor/static/css/site.css | 149 +++++++++++++----- sites/webmd_doctor/templates/_mini_card.html | 4 +- .../templates/award_recipients.html | 19 ++- sites/webmd_doctor/templates/awards.html | 12 +- sites/webmd_doctor/templates/doctor.html | 106 +++++++------ sites/webmd_doctor/templates/guidelines.html | 33 ++-- sites/webmd_doctor/templates/hospital.html | 12 +- sites/webmd_doctor/templates/hub_index.html | 40 +++++ sites/webmd_doctor/templates/hub_list.html | 12 +- sites/webmd_doctor/templates/index.html | 10 +- sites/webmd_doctor/templates/practice.html | 22 +-- sites/webmd_doctor/templates/signup.html | 5 +- .../templates/specialty_city.html | 2 +- .../templates/specialty_landing.html | 4 +- .../templates/specialty_state.html | 4 +- 16 files changed, 358 insertions(+), 175 deletions(-) create mode 100644 sites/webmd_doctor/templates/hub_index.html diff --git a/sites/webmd_doctor/app.py b/sites/webmd_doctor/app.py index 3529b45d..47873b4b 100644 --- a/sites/webmd_doctor/app.py +++ b/sites/webmd_doctor/app.py @@ -1426,8 +1426,11 @@ def specialty_doctor_rows(specialty: Specialty): return query.all() -def highest_rated_near_anchor(specialty: Specialty, anchor: City, limit: int) -> list[Doctor]: - """Top-rated doctors of a specialty inside the default search radius of the anchor.""" +STRIP_RADIUS_MILES = 25.0 # "Highest Rated ... near Newark" strip: the 25-mile ring, not the 40-mile search default + + +def highest_rated_near_anchor(specialty: Specialty, anchor: City, limit: int, radius: float = STRIP_RADIUS_MILES) -> list[Doctor]: + """Top-rated doctors of a specialty whose primary office is within `radius` miles of the anchor.""" rated = ( Doctor.query.filter(Doctor.primary_specialty_id == specialty.id, Doctor.avg_rating.isnot(None)) .order_by(Doctor.avg_rating.desc(), Doctor.ratings_count.desc(), Doctor.id) @@ -1436,7 +1439,7 @@ def highest_rated_near_anchor(specialty: Specialty, anchor: City, limit: int) -> nearby = [] for doctor in rated: location = doctor.primary_location - if haversine_miles(anchor.lat, anchor.lon, location.lat, location.lon) <= DEFAULT_DISTANCE: + if haversine_miles(anchor.lat, anchor.lon, location.lat, location.lon) <= radius: nearby.append(doctor) if len(nearby) == limit: break @@ -1545,36 +1548,60 @@ def specialty_city(spec: str, state: str, city: str): ) +HUB_CARE_TYPES = ( + ("Cardiology", "cardiovascular-disease"), ("Gastroenterology", "gastroenterology"), ("Neurology", "neurology"), + ("Orthopedics", "orthopedic-surgery"), ("Psychiatry", "psychiatry"), +) + + +def hub_counts(kind: str, items: list) -> dict: + counts = {} + for item in items: + if kind == "hospitals": + doctors = item.doctors + else: + doctors = list({loc.doctor_id: loc.doctor for loc in item.locations}.values()) + counts[item.id] = {"physicians": len(doctors), "specialties": len({d.primary_specialty_id for d in doctors})} + return counts + + def hub_page(kind: str, state_slug: str | None): model = Hospital if kind == "hospitals" else Practice items = model.query.all() - state_name = None - state_code = None - if state_slug is not None: - city = City.query.filter_by(state_slug=state_slug).order_by(City.id).first() - if city is None: - abort(404) - state_name, state_code = city.state_name, city.state - items = [item for item in items if item.city.state == state_code] - states = states_with_counts([(item.city.state, item.city.state_name, item.city.state_slug) for item in model.query.all()]) + states = states_with_counts([(item.city.state, item.city.state_name, item.city.state_slug) for item in items]) + if state_slug is None: + anchor = anchor_city() + in_state = [item for item in items if item.city.state == anchor.state] + top_items = hub_sorted(in_state, "avg_rating", 0)[:4] + return render_template( + "hub_index.html", + kind=kind, + title="Hospitals" if kind == "hospitals" else "Group Practices", + states=states, + total=len(items), + anchor=anchor, + top_items=top_items, + counts=hub_counts(kind, top_items), + care_types=HUB_CARE_TYPES, + ) + city = City.query.filter_by(state_slug=state_slug).order_by(City.id).first() + if city is None: + abort(404) + state_name, state_code = city.state_name, city.state + items = [item for item in items if item.city.state == state_code] + name_filter = single_arg("name", "").strip()[:80] + if name_filter: + items = [item for item in items if name_filter.lower() in item.name.lower()] sortby = single_arg("sortby", "bestmatch") if sortby not in {key for key, _label in HUB_SORT_OPTIONS}: sortby = "bestmatch" minrating = int_arg("minrating", 0, 1, 5) if single_arg("minrating", "").isdigit() else 0 ordered = hub_sorted(items, sortby, minrating) page = paginate(ordered, int_arg("page", 1, 1, 10**4)) - counts = {} - for item in page["rows"]: - if kind == "hospitals": - doctors = item.doctors - else: - doctors = list({loc.doctor_id: loc.doctor for loc in item.locations}.values()) - counts[item.id] = { - "physicians": len(doctors), - "specialties": len({d.primary_specialty_id for d in doctors}), - } + counts = hub_counts(kind, page["rows"]) return render_template( "hub_list.html", + name_filter=name_filter, kind=kind, title="Hospitals" if kind == "hospitals" else "Group Practices", noun="Hospital" if kind == "hospitals" else "Group Practice", @@ -1588,8 +1615,8 @@ def hub_page(kind: str, state_slug: str | None): minrating=minrating, city_count=len({item.city_id for item in items}), total=len(items), - base_path=url_for(f"{kind}_index") if state_slug is None else url_for(f"{kind}_state", state=state_slug), - page_qs=lambda n: urlencode({k: v for k, v in (("sortby", sortby if sortby != "bestmatch" else ""), ("minrating", minrating or ""), ("page", n)) if v not in ("", None)}), + base_path=url_for(f"{kind}_state", state=state_slug), + page_qs=lambda n: urlencode({k: v for k, v in (("name", name_filter), ("sortby", sortby if sortby != "bestmatch" else ""), ("minrating", minrating or ""), ("page", n)) if v not in ("", None)}), ) @@ -1626,18 +1653,25 @@ def hospital_detail(slug: str): if hospital is None: abort(404) doctors = sorted(hospital.doctors, key=lambda d: (d.last_name.lower(), d.first_name.lower(), d.id)) - page = paginate(doctors, int_arg("pagenumber", 1, 1, 10**4)) + specialty_filter = single_arg("specialty", "") + specialty_options = sorted({d.primary_specialty for d in doctors}, key=lambda s: s.name) + if specialty_filter not in {s.slug for s in specialty_options}: + specialty_filter = "" + listed = [d for d in doctors if not specialty_filter or d.primary_specialty.slug == specialty_filter] + page = paginate(listed, int_arg("pagenumber", 1, 1, 10**4)) return render_template( "hospital.html", hospital=hospital, page=page, + specialty_filter=specialty_filter, + specialty_options=specialty_options, doctors=doctors, specialty_rows=specialty_counts(doctors), poll_rows=hospital.poll_rows(HOSPITAL_POLL_QUESTIONS), top_specialties=[name for name, _count in sorted(specialty_counts(doctors), key=lambda row: (-row[1], row[0]))[:4]], award_count=sum(len(d.awards) for d in doctors), base_path=url_for("hospital_detail", slug=slug), - page_qs=lambda n: urlencode({"pagenumber": n}), + page_qs=lambda n: urlencode({k: v for k, v in (("specialty", specialty_filter), ("pagenumber", n)) if v}), ) @@ -1692,6 +1726,13 @@ def award_recipients(): class_name, line, title = AWARD_CLASSES[award_class] awards = Award.query.filter_by(award_class=class_name).all() doctors = sorted({award.doctor_id: award.doctor for award in awards}.values(), key=lambda d: (d.last_name.lower(), d.first_name.lower(), d.id)) + state_options = states_with_counts([(d.primary_location.city.state, d.primary_location.city.state_name, d.primary_location.city.state_slug) for d in doctors]) + state_filter = single_arg("state", "") + if state_filter not in {s["slug"] for s in state_options}: + state_filter = "" + if state_filter: + doctors = [d for d in doctors if d.primary_location.city.state_slug == state_filter] + doctors.sort(key=lambda d: (d.primary_location.city.state_name, d.last_name.lower(), d.first_name.lower(), d.id)) page = paginate(doctors, int_arg("page", 1, 1, 10**4)) years = {award.doctor_id: award.year for award in awards} return render_template( @@ -1701,9 +1742,11 @@ def award_recipients(): award_line=line, page=page, years=years, + state_options=state_options, + state_filter=state_filter, saved_ids=saved_doctor_ids(), base_path=url_for("award_recipients"), - page_qs=lambda n: urlencode({"award-class": award_class, "page": n}), + page_qs=lambda n: urlencode({k: v for k, v in (("award-class", award_class), ("state", state_filter), ("page", n)) if v}), ) @@ -1754,7 +1797,7 @@ def signup(): dob = None if form["dob"]: try: - dob = date.fromisoformat(form["dob"]) + dob = datetime.strptime(form["dob"], "%m/%d/%Y").date() if "/" in form["dob"] else date.fromisoformat(form["dob"]) except ValueError: errors.append("Enter your date of birth as YYYY-MM-DD.") if not errors: diff --git a/sites/webmd_doctor/static/css/site.css b/sites/webmd_doctor/static/css/site.css index 0c32c4b2..7634fc30 100644 --- a/sites/webmd_doctor/static/css/site.css +++ b/sites/webmd_doctor/static/css/site.css @@ -49,7 +49,7 @@ button, input, select, textarea { font: inherit; } .header-bar { display: flex; align-items: center; justify-content: space-between; height: 52px; gap: 16px; } .brand { display: flex; align-items: center; color: #fff; text-decoration: none; } .brand:hover { text-decoration: none; } -.brand svg { height: 26px; width: auto; display: block; } +.brand svg { height: 30px; width: auto; display: block; } .header-nav { display: flex; align-items: center; gap: 28px; font-size: 16px; font-weight: 600; } .header-nav > li { position: relative; } .header-nav a, .header-nav .nav-btn { color: #fff; display: inline-flex; align-items: center; gap: 6px; background: none; border: 0; padding: 14px 0; cursor: pointer; font-weight: 600; } @@ -62,18 +62,22 @@ button, input, select, textarea { font: inherit; } .menu .menu-title { padding: 6px 20px 8px; font-weight: 700; color: var(--card-navy); column-span: all; border-bottom: 1px solid var(--border); margin-bottom: 6px; } .menu-account { min-width: 220px; } .search-row { padding: 0 0 14px; } -.search-bar { display: flex; background: #fff; border-radius: 4px; overflow: hidden; height: 52px; box-shadow: 0 1px 3px rgba(0,0,0,.25); } +.search-bar { display: flex; background: #fff; border-radius: 4px; height: 52px; box-shadow: 0 1px 3px rgba(0,0,0,.25); } +.search-bar .field:first-child { border-radius: 4px 0 0 4px; } .search-bar .field { display: flex; align-items: center; gap: 10px; flex: 1; padding: 0 16px; border-right: 1px solid var(--border); position: relative; } .search-bar .field:last-of-type { border-right: 0; } .search-bar input { border: 0; outline: 0; width: 100%; height: 100%; font-size: 17px; color: var(--text); background: transparent; } .search-bar input::placeholder { color: #6b6b6b; } .search-bar .ic { color: var(--blue); width: 18px; height: 18px; } -.search-bar button { border: 0; background: var(--blue); color: #fff; font-weight: 700; padding: 0 28px; display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 15px; letter-spacing: .3px; } +.search-bar button { border: 0; border-radius: 0 4px 4px 0; background: var(--blue); color: #fff; font-weight: 700; padding: 0 28px; display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 15px; letter-spacing: .3px; } .search-bar button .ic { color: #fff; } .typeahead { position: absolute; top: 100%; left: 0; right: 0; background: #fff; color: var(--text); border: 1px solid var(--border); border-top: 0; z-index: 50; display: none; max-height: 320px; overflow: auto; text-align: left; } .typeahead.open { display: block; } -.typeahead .ta-section { padding: 8px 16px 4px; font-size: 12px; font-weight: 700; letter-spacing: 1px; color: var(--muted); } -.typeahead a { display: block; padding: 6px 16px; color: var(--text); } +.typeahead .ta-section { padding: 10px 16px 4px; font-size: 12px; font-weight: 700; letter-spacing: 1px; color: var(--blue); border-top: 1px solid var(--border); } +.typeahead .ta-section:first-child { border-top: 0; } +.typeahead a { display: block; padding: 6px 16px; color: var(--text); font-size: 15px; } +.typeahead a b { color: var(--blue); } +.typeahead a.ta-specialty { text-transform: uppercase; font-weight: 600; } .typeahead a:hover, .typeahead a.active { background: var(--bg-2); text-decoration: none; } /* ---------- home ---------- */ @@ -86,7 +90,7 @@ button, input, select, textarea { font: inherit; } .chip:hover { background: rgba(255,255,255,.12); text-decoration: none; } .home-promo { background: var(--bg-2); padding: 70px 0; } .home-promo .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; align-items: center; } -.home-promo h2 { font-size: 40px; font-weight: 300; color: var(--text-2); margin-bottom: 24px; } +.home-promo h2 { font-size: 42px; font-weight: 300; color: var(--text-2); margin-bottom: 24px; line-height: 1.1; } .home-promo li { display: flex; gap: 20px; font-size: 20px; margin: 10px 0 10px 40px; } .home-promo li::before { content: ""; width: 14px; height: 4px; background: var(--blue); margin-top: 12px; flex: none; border-radius: 2px; } .promo-art { position: relative; height: 380px; } @@ -97,30 +101,36 @@ button, input, select, textarea { font: inherit; } .promo-card.facts li::before { display: none; } .promo-photo { position: absolute; left: 100px; top: 0; width: 360px; height: 360px; border-radius: 8px; background: linear-gradient(160deg, #c9d3ff, #5f7cff 70%, #00157c); } .home-section { padding: 48px 0; background: #fff; } -.home-section h2 { font-size: 30px; font-weight: 300; color: var(--card-navy); margin-bottom: 24px; } -.spec-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; } -.spec-tile { display: block; text-align: center; padding: 22px 8px; border: 1px solid var(--border); border-radius: 6px; color: var(--text-2); font-weight: 700; font-size: 13px; letter-spacing: .8px; text-transform: uppercase; } -.spec-tile:hover { border-color: var(--blue); color: var(--blue); text-decoration: none; } +.home-section { padding: 60px 0 54px; } +.home-section h2 { font-size: 40px; font-weight: 300; color: var(--card-navy); margin-bottom: 40px; } +.spec-row { display: grid; grid-template-columns: repeat(10, 1fr); gap: 8px; } +.spec-tile { display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center; padding: 8px 4px; color: var(--text-2); font-weight: 700; font-size: 12px; letter-spacing: .8px; text-transform: uppercase; line-height: 1.2; } +.spec-tile .glyph { width: 64px; height: 64px; border-radius: 50%; border: 2px solid var(--card-navy); color: var(--card-navy); display: flex; align-items: center; justify-content: center; } +.spec-tile .glyph .ic { width: 30px; height: 30px; } +.spec-tile:hover { color: var(--blue); text-decoration: none; } +.spec-tile:hover .glyph { border-color: var(--blue); color: var(--blue); } .top-docs { text-align: center; } -.top-docs .kicker { color: var(--card-navy); font-weight: 700; font-size: 18px; } -.top-docs .place { color: var(--blue); font-size: 18px; margin-bottom: 20px; } +.top-docs .kicker { color: var(--card-navy); font-weight: 700; font-size: 22px; } +.top-docs .place { color: var(--blue); font-size: 22px; margin-bottom: 26px; } .mini-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; } .mini-grid.six { grid-template-columns: repeat(5, 1fr); } .mini-grid.three { grid-template-columns: repeat(3, 1fr); max-width: 900px; margin: 0 auto; } .mini-card { background: #fff; border: 1px solid var(--border); border-radius: 6px; padding: 22px 14px; text-align: center; } -.mini-card .avatar { width: 64px; height: 64px; border-radius: 50%; margin: 0 auto 10px; display: block; } +.mini-card .avatar { width: 70px; height: 70px; border-radius: 50%; margin: 0 auto 10px; display: block; } .mini-card .name { font-weight: 700; font-size: 16px; color: var(--text); } .mini-card .spec { font-size: 13px; color: var(--text-3); } .mini-card .rating-line { font-size: 12px; margin: 6px 0; justify-content: center; flex-wrap: wrap; gap: 4px; } -.mini-card .meta { font-size: 13px; color: var(--text-3); } -.mini-card .btn { margin-top: 12px; } +.mini-card .meta { font-size: 13px; color: var(--text-3); display: flex; flex-direction: column; gap: 2px; align-items: center; } +.mini-card .btn { margin-top: 14px; } +.btn.upper { text-transform: uppercase; letter-spacing: .5px; } .awards-band { background: var(--navy); color: #fff; padding: 60px 0; } .awards-band .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; align-items: center; } -.awards-band h3 { font-size: 18px; margin-bottom: 4px; } -.awards-band h2 { font-size: 30px; font-weight: 400; margin-bottom: 16px; } +.awards-band h3 { font-size: 20px; margin-bottom: 4px; } +.awards-band h2 { font-size: 40px; font-weight: 600; margin-bottom: 18px; } +.awards-band p { font-size: 17px; line-height: 1.5; max-width: 560px; } .awards-band .art { height: 220px; border-radius: 8px; background: radial-gradient(circle at 30% 30%, #2b3f9e, var(--navy) 70%); } .by-specialty { background: var(--bg-2); padding: 50px 0; } -.by-specialty h2 { text-align: center; font-size: 34px; font-weight: 300; color: var(--card-navy); margin-bottom: 30px; } +.by-specialty h2 { text-align: center; font-size: 40px; font-weight: 300; color: var(--card-navy); margin-bottom: 36px; } .by-specialty .label { font-weight: 700; color: var(--card-navy); letter-spacing: .5px; font-size: 14px; margin-bottom: 12px; } .spec-cols { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px 20px; } .spec-cols a { color: var(--text-2); font-size: 15px; } @@ -142,9 +152,10 @@ button, input, select, textarea { font: inherit; } .notice { background: #fff7e0; border: 1px solid #f2d78a; border-radius: 4px; padding: 10px 14px; margin: 0 0 16px; font-size: 14px; } .filter-bar { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 18px; } .filter-item { position: relative; } -.pill { display: inline-flex; align-items: center; gap: 10px; height: 42px; padding: 0 16px; border: 1px solid var(--border-2); border-radius: 3px; background: #fff; color: var(--blue); font-weight: 600; cursor: pointer; font-size: 16px; } -.pill .ic { color: var(--blue); } -.pill.active { background: var(--bg-2); border-color: var(--blue); } +.pill { display: inline-flex; align-items: center; gap: 10px; height: 42px; padding: 0 16px; border: 1px solid var(--border-2); border-radius: 3px; background: #fff; color: var(--text); font-weight: 600; cursor: pointer; font-size: 16px; } +.pill .ic { color: var(--text-2); } +.pill.active { border-color: var(--navy); box-shadow: inset 0 0 0 1px var(--navy); } +.pill-icon .ic { color: var(--blue); } .pill input[type=checkbox] { width: 18px; height: 18px; accent-color: var(--blue); margin: 0; } .pill-icon { width: 42px; justify-content: center; padding: 0; } .popover { position: absolute; top: 100%; left: 0; margin-top: 6px; z-index: 30; background: #fff; border: 1px solid var(--border); border-radius: 4px; box-shadow: 0 8px 24px rgba(0,0,0,.16); padding: 14px 16px; min-width: 240px; display: none; } @@ -195,10 +206,12 @@ button, input, select, textarea { font: inherit; } .pagination .current { background: var(--blue); color: #fff; } .pagination .ellipsis { color: var(--muted); } .pagination a.nav { background: var(--blue); color: #fff; border-color: var(--blue); } -.pagination a.nav[aria-disabled=true] { opacity: .35; pointer-events: none; } +.pagination span.nav { color: #c8c8c8; } /* ---------- doctor profile ---------- */ -.profile-hero { background: var(--card-navy); color: #fff; border-radius: 4px; position: relative; overflow: hidden; display: grid; grid-template-columns: 1fr 400px; } +.profile-top { position: relative; margin-bottom: 20px; } +.profile-hero { background: var(--card-navy); color: #fff; border-radius: 4px 4px 0 0; position: relative; overflow: hidden; } +.has-widget .profile-hero { padding-right: 424px; min-height: 470px; } .profile-hero .hero-main { padding: 22px 28px 28px; display: grid; grid-template-columns: 170px 1fr; gap: 20px; position: relative; z-index: 1; } .profile-hero .hero-art { position: absolute; right: 0; bottom: 0; width: 520px; height: 300px; opacity: .55; pointer-events: none; } .profile-hero .avatar-col { text-align: center; } @@ -217,26 +230,39 @@ button, input, select, textarea { font: inherit; } .affiliation { position: absolute; top: 18px; right: 20px; background: #fff; color: var(--text-2); border-radius: 4px; padding: 8px 14px; display: flex; align-items: center; gap: 12px; font-size: 11px; line-height: 1.2; z-index: 2; } .affiliation strong { font-size: 13px; color: var(--card-navy); } .affiliation a { color: var(--card-navy); text-decoration: none; } -.hero-side { background: #fff; color: var(--text); padding: 70px 24px 24px; } +.hero-side { position: absolute; top: 92px; right: 20px; width: 384px; background: #fff; color: var(--text); padding: 22px 24px 24px; border: 1px solid var(--border); border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,.12); z-index: 3; } .hero-side h3 { font-size: 18px; margin-bottom: 14px; } -.hero-side.rail-only { padding-top: 70px; } +.grid-more { text-align: center; color: var(--blue); margin-top: 6px; } +.grid-more .ic { transform: rotate(0deg); } .book-widget label.lbl { display: block; font-weight: 700; font-size: 14px; margin: 10px 0 6px; } .book-widget select { width: 100%; padding: 10px; border: 1px solid var(--border-2); border-radius: 3px; } .seg { display: flex; gap: 10px; } .seg label { flex: 1; display: flex; align-items: center; gap: 8px; border: 1px solid var(--border-2); border-radius: 3px; padding: 8px 10px; font-size: 14px; cursor: pointer; } .seg label.on { border-color: var(--blue); } .month { text-align: center; font-weight: 700; margin: 12px 0 8px; color: var(--card-navy); } +.month .ic { color: var(--blue); } .grid-days { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; } .grid-days .day-head { text-align: center; font-size: 12px; font-weight: 700; line-height: 1.2; } .grid-days .day-head span { display: block; font-weight: 400; } .slot { display: block; text-align: center; font-size: 12px; padding: 6px 2px; border: 1px solid var(--border-2); border-radius: 3px; cursor: pointer; margin-top: 6px; } .slot input { position: absolute; opacity: 0; } .slot.on, .slot:has(input:checked) { background: var(--bg-2); border-color: var(--blue); color: var(--blue); font-weight: 700; } -.tabs { display: flex; gap: 20px; background: #fff; border: 1px solid var(--border); border-top: 0; padding: 0 20px; margin-bottom: 20px; } +.tabs { display: flex; gap: 20px; background: #fff; border: 1px solid var(--border); border-top: 0; padding: 0 20px; border-radius: 0 0 4px 4px; } +.has-widget .tabs { margin-right: 424px; } .tabs a { padding: 14px 0; font-weight: 700; color: var(--text-2); border-bottom: 3px solid transparent; font-size: 14px; letter-spacing: .3px; } .tabs a.on { color: var(--card-navy); border-color: var(--blue); } .tabs a:hover { text-decoration: none; color: var(--blue); } .profile-grid { display: grid; grid-template-columns: 1fr 400px; gap: 24px; align-items: start; } +.wide-panel { grid-column: 1 / -1; background: #fff; border: 1px solid var(--border); border-radius: 4px; padding: 22px 28px; font-size: 15px; color: var(--text-2); } +.wide-panel h3 { font-size: 16px; margin-bottom: 12px; color: var(--text); } +.wide-panel + .wide-panel { margin-top: -8px; } +.rail-panel h3 { font-size: 16px; margin-bottom: 12px; } +.colleague-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px 14px; } +.colleague { text-align: center; font-size: 13px; color: var(--text-3); } +.colleague .avatar { width: 64px; height: 64px; border-radius: 50%; margin: 0 auto 8px; display: block; } +.colleague .name { font-weight: 700; font-size: 14px; } +.colleague .name a { color: var(--text); text-decoration: underline; } +.colleague .stars { justify-content: center; margin-top: 4px; } .stack { display: flex; flex-direction: column; gap: 16px; min-width: 0; } .rail { display: flex; flex-direction: column; gap: 16px; position: sticky; top: 16px; } .panel { background: #fff; border: 1px solid var(--border); border-radius: 4px; padding: 22px 28px 26px; } @@ -249,6 +275,7 @@ button, input, select, textarea { font: inherit; } .panel-body li { line-height: 1.5; } .bio ul { list-style: disc; padding-left: 22px; margin: 6px 0 12px; } .bio strong { color: var(--card-navy); } +.bio > p:first-child strong:first-child, .bio .meet { color: #d9541e; } .bio-clip { max-height: 260px; overflow: hidden; position: relative; } .bio-clip.expanded { max-height: none; } .video-poster { position: relative; display: block; } @@ -259,6 +286,10 @@ button, input, select, textarea { font: inherit; } .loc .practice-link { font-weight: 700; text-decoration: underline; color: var(--text); display: inline-block; margin-bottom: 4px; } .loc .loc-name { font-weight: 700; } .loc .map { background: #dedede; border-radius: 3px; height: 120px; display: flex; align-items: center; justify-content: center; color: #7a7a7a; font-size: 12px; } +.loc.stacked { grid-template-columns: 1fr; } +.loc.stacked .map { height: 170px; } +.flag-lines { margin: 12px 0 0 22px; font-size: 14px; line-height: 1.6; } +.loc.stacked .hours { margin-left: 22px; } .hours { display: grid; grid-template-columns: 44px 1fr; gap: 2px 8px; font-size: 14px; margin-top: 10px; } .hours dt, .hours dd { margin: 0; } .review-summary { display: grid; grid-template-columns: 1fr auto; gap: 16px; align-items: start; } @@ -270,6 +301,11 @@ button, input, select, textarea { font: inherit; } .trust { background: var(--bg-2); border: 1px solid #c9d1ff; border-radius: 4px; padding: 12px 16px; font-weight: 700; color: var(--card-navy); display: flex; gap: 12px; align-items: center; margin: 12px 0; } .review-head { display: flex; align-items: center; justify-content: space-between; margin: 16px 0 8px; } .review-head h3 { font-size: 15px; letter-spacing: .5px; } +.review-controls { display: flex; gap: 10px; align-items: center; font-size: 14px; color: var(--text-2); } +.review-controls span { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border-2); border-radius: 3px; padding: 6px 12px; background: #fff; } +.review-controls .fake-select { min-width: 150px; justify-content: space-between; } +.review-controls .fake-btn { border-radius: 20px; } +.review-controls .fake-search { min-width: 150px; justify-content: space-between; color: var(--muted); } .review { padding: 14px 0; border-bottom: 1px solid var(--border); } .review:last-child { border-bottom: 0; } .review .text { margin: 6px 0; font-size: 15px; line-height: 1.5; } @@ -285,8 +321,13 @@ details.review-form > summary::-webkit-details-marker { display: none; } .form-grid label { font-size: 14px; font-weight: 600; } .form-grid input[type=text], .form-grid input[type=email], .form-grid input[type=password], .form-grid input[type=date], .form-grid textarea, .form-grid select { width: 100%; padding: 10px 12px; border: 1px solid var(--border-2); border-radius: 3px; font-size: 15px; } .form-grid textarea { min-height: 110px; resize: vertical; } -.star-pick { display: flex; gap: 12px; align-items: center; } -.star-pick label { display: inline-flex; align-items: center; gap: 4px; cursor: pointer; font-weight: 400; } +.star-pick { display: flex; gap: 4px; align-items: center; position: relative; } +.star-pick label { display: inline-flex; align-items: center; cursor: pointer; font-weight: 400; position: relative; } +.star-pick input { position: absolute; opacity: 0; width: 28px; height: 28px; margin: 0; cursor: pointer; } +.star-pick label .ic { width: 28px; height: 28px; color: #d8d8d8; } +.star-pick label:hover .ic { color: #f5d67a; } +.star-pick:has(input[value="1"]:checked) label:nth-of-type(-n+1) .ic, .star-pick:has(input[value="2"]:checked) label:nth-of-type(-n+2) .ic, .star-pick:has(input[value="3"]:checked) label:nth-of-type(-n+3) .ic, .star-pick:has(input[value="4"]:checked) label:nth-of-type(-n+4) .ic, .star-pick:has(input[value="5"]:checked) label:nth-of-type(-n+5) .ic { color: var(--gold); } +.star-hint { margin-left: 10px; font-size: 14px; color: var(--text-3); } .crit-rows { display: grid; gap: 8px; } .crit-row { display: grid; grid-template-columns: 1fr auto auto; gap: 14px; align-items: center; font-size: 14px; } .crit-row label { font-weight: 400; display: inline-flex; gap: 4px; align-items: center; } @@ -298,8 +339,8 @@ details.review-form > summary::-webkit-details-marker { display: none; } .cond .tier-labels { display: grid; grid-template-columns: repeat(3, 1fr); font-size: 13px; text-align: center; color: var(--text-3); } .cond .tier-labels .on { color: var(--card-navy); font-weight: 700; } .top20 summary { color: var(--blue); cursor: pointer; text-decoration: underline; margin-top: 8px; } -.top20 ol { padding-left: 22px; margin: 8px 0 0; columns: 2; } -.top20 ol li { padding: 2px 0; } +.top20 ol { padding-left: 22px; margin: 12px 0 0; columns: 2; list-style: disc; } +.top20 ol li { padding: 3px 0; break-inside: avoid; } .inline-list { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 20px; } .inline-list li { padding: 2px 0; } .award-box { display: flex; gap: 16px; align-items: flex-start; } @@ -371,6 +412,7 @@ details.review-form > summary::-webkit-details-marker { display: none; } .auth-form .remember { display: flex; align-items: center; gap: 8px; margin-top: 12px; font-size: 15px; } .auth-form .forgot { display: block; margin: -10px 0 20px; } .auth-close { position: absolute; right: 24px; top: 18px; font-size: 28px; color: var(--text-3); } +.fine-print { font-size: 12px; color: var(--text-3); margin-top: 14px; line-height: 1.5; } /* ---------- specialty / hub pages ---------- */ .page-title { font-size: 34px; font-weight: 700; color: var(--card-navy); margin-bottom: 6px; } @@ -380,7 +422,7 @@ details.review-form > summary::-webkit-details-marker { display: none; } .chip-row { display: flex; flex-wrap: wrap; gap: 10px; margin: 12px 0 24px; } .chip-row a { border: 1px solid var(--blue); color: var(--blue); border-radius: 9999px; padding: 6px 18px; font-weight: 700; font-size: 14px; background: #fff; } .chip-row a:hover { background: var(--bg-2); text-decoration: none; } -.h2 { font-size: 26px; font-weight: 700; color: var(--text); margin: 18px 0 14px; } +.h2 { font-size: 30px; font-weight: 700; color: var(--text); margin: 22px 0 16px; } .stat-row { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin: 18px 0 30px; } .stat { background: #fff; border: 1px solid var(--border); border-radius: 4px; display: grid; grid-template-columns: 220px 1fr; } .stat .n { padding: 18px 20px; border-right: 1px solid var(--border); display: flex; align-items: center; gap: 10px; font-size: 40px; font-weight: 700; color: var(--card-navy); } @@ -394,12 +436,30 @@ details.review-form > summary::-webkit-details-marker { display: none; } .spec-index a { color: var(--text); font-size: 17px; } .center-panel { background: #fff; border: 1px solid var(--border); border-radius: 4px; padding: 28px 36px 40px; } .center-panel h1 { text-align: center; font-size: 26px; margin-bottom: 24px; } +.name-search { display: flex; gap: 0; max-width: 600px; margin: 4px 0 36px; } +.name-search input { flex: 1; padding: 10px 14px; border: 1px solid var(--border-2); border-radius: 3px 0 0 3px; font-size: 15px; } +.name-search .btn { border-radius: 0 3px 3px 0; padding: 10px 24px; } +.hub-mini-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; } +.hub-mini { background: #fff; border: 1px solid var(--border); border-radius: 4px; padding: 20px; display: flex; flex-direction: column; gap: 4px; } +.hub-mini h3 { font-size: 18px; } +.hub-mini h3 a { color: var(--text); } +.hub-mini .sub { font-size: 13px; color: var(--text-2); } +.hub-mini .btn { margin-top: auto; } +.find-doctors-strip { background: #fff; border: 1px solid var(--border); border-radius: 4px; padding: 16px 20px; margin-top: 30px; font-size: 14px; } +.find-doctors-strip .links { display: flex; flex-wrap: wrap; margin-top: 10px; } +.find-doctors-strip .links a { color: var(--text-2); padding: 3px 12px; border-right: 1px solid var(--border-2); } +.find-doctors-strip .links a:last-child { border-right: 0; } +.spec-select-form { margin: 0 0 16px; } +.spec-select-form select { min-width: 300px; padding: 8px 10px; border: 1px solid var(--border-2); border-radius: 3px; background: #fff; } .hub-card { background: #fff; border: 1px solid var(--border); border-radius: 4px; padding: 22px 26px; display: grid; grid-template-columns: 1fr 200px; gap: 20px; } .hub-card h3 { font-size: 22px; } .hub-card h3 a { color: var(--text); } .hub-card .sub { font-size: 14px; color: var(--text-2); } .hub-card .desc { font-size: 14px; color: var(--text-2); margin-top: 8px; } -.hub-hero { background: linear-gradient(90deg, var(--bg-2) 60%, #c9d1ff); border-radius: 4px 4px 0 0; padding: 24px 36px; } +.hub-hero { background: linear-gradient(90deg, var(--bg-2) 60%, #c9d1ff); border-radius: 4px 4px 0 0; padding: 24px 36px; position: relative; overflow: hidden; } +.hub-hero::after { content: ""; position: absolute; right: -40px; top: -60px; width: 200px; height: 200px; border-radius: 50%; background: var(--card-navy); opacity: .7; } +.hub-hero::before { content: ""; position: absolute; right: 120px; top: 10px; width: 160px; height: 160px; border-radius: 50%; border: 2px solid #fff; opacity: .6; } +.hub-hero h1 { position: relative; z-index: 1; } .hub-hero h1 { color: var(--card-navy); font-size: 38px; } .hub-hero.navy { background: var(--card-navy); } .hub-hero.navy h1 { color: #fff; } @@ -426,16 +486,27 @@ details.review-form > summary::-webkit-details-marker { display: none; } .awards-hero .seals { display: flex; gap: 14px; } .awards-hero .seal { width: 110px; height: 110px; border-radius: 50%; background: #fff; color: var(--card-navy); display: flex; align-items: center; justify-content: center; text-align: center; font-size: 11px; font-weight: 700; line-height: 1.15; padding: 10px; border: 4px solid #c9d1ff; } .awards-nav { background: #f6f6f6; border-bottom: 1px solid var(--border); } -.awards-nav ul { display: flex; justify-content: space-around; padding: 12px 0; font-weight: 600; } +.awards-nav ul { display: flex; justify-content: space-around; padding: 14px 0; font-weight: 600; font-size: 15px; } .awards-method { background: var(--bg-2); text-align: center; padding: 50px 0; } .awards-method h2 { font-size: 40px; color: var(--card-navy); margin-bottom: 14px; } .awards-method p { max-width: 720px; margin: 0 auto; font-size: 20px; line-height: 1.4; color: var(--text-2); } .awards-section { padding: 50px 0; background: #fff; } .awards-section:nth-of-type(even) { background: #f7f7f7; } -.awards-section h2 { font-size: 34px; color: var(--card-navy); margin-bottom: 12px; } +.awards-section .grid { display: grid; grid-template-columns: 380px 1fr; gap: 50px; align-items: center; } +.awards-section .art { height: 250px; border-radius: 6px; background: linear-gradient(150deg, #dfe6ff, #6f8cff 60%, var(--card-navy)); } +.awards-section .art.alt { background: linear-gradient(210deg, #e9edff, #8ea3ff 55%, #1d3ba8); } +.awards-section h2 { font-size: 40px; color: var(--card-navy); margin-bottom: 12px; } +.awards-section p { font-size: 16px; line-height: 1.5; } .awards-section .links { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 30px; font-weight: 700; font-size: 18px; margin-top: 14px; max-width: 560px; } .filters-line { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; font-size: 15px; } .filters-line .tag { border: 1px solid var(--blue); color: var(--blue); border-radius: 9999px; padding: 6px 16px; font-weight: 600; } +.filters-line .tag-select { border: 1px solid var(--blue); color: var(--blue); border-radius: 9999px; padding: 6px 14px; font-weight: 600; background: #fff; } +.state-head { font-size: 20px; font-weight: 400; color: var(--text); margin: 6px 0 -4px; } +.guidelines { max-width: 1130px; margin: 24px auto 40px; padding: 48px 170px 60px; font-size: 15px; color: var(--text-2); } +.guidelines h1 { text-align: center; font-size: 18px; margin-bottom: 26px; color: var(--text); } +.guidelines ol { padding-left: 34px; margin: 20px 0 30px; } +.guidelines li { margin: 0 0 22px; line-height: 1.55; padding-left: 8px; } +.guidelines p { line-height: 1.55; } .static-page h1 { font-size: 30px; margin-bottom: 16px; color: var(--card-navy); } .static-page h2 { font-size: 20px; margin: 22px 0 8px; } .static-page p, .static-page li { line-height: 1.55; color: var(--text-2); } @@ -478,7 +549,12 @@ details.review-form > summary::-webkit-details-marker { display: none; } .profile-hero, .profile-grid { grid-template-columns: 1fr; } .profile-hero .hero-art { display: none; } .rail { position: static; } - .spec-grid, .browse-grid, .spec-cols { grid-template-columns: repeat(3, 1fr); } + .spec-row, .browse-grid, .spec-cols { grid-template-columns: repeat(5, 1fr); } + .has-widget .profile-hero { padding-right: 28px; } + .hero-side { position: static; width: auto; margin: 0 0 16px; } + .has-widget .tabs { margin-right: 0; } + .hub-mini-grid, .colleague-grid { grid-template-columns: repeat(2, 1fr); } + .awards-section .grid { grid-template-columns: 1fr; } .mini-grid.six { grid-template-columns: repeat(3, 1fr); } .auth-modal { grid-template-columns: 1fr; } .auth-art { display: none; } @@ -494,7 +570,8 @@ details.review-form > summary::-webkit-details-marker { display: none; } .phys-card { flex-direction: column; } .phys-card .cta-col { width: 100%; } .profile-hero .hero-main { grid-template-columns: 1fr; } - .spec-grid, .browse-grid, .spec-cols, .mini-grid, .mini-grid.six, .spec-index, .phys-grid, .criteria-grid, .stat-row, .footer-links .cols { grid-template-columns: 1fr; } + .browse-grid, .spec-cols, .mini-grid, .mini-grid.six, .spec-index, .phys-grid, .criteria-grid, .stat-row, .footer-links .cols, .hub-mini-grid, .colleague-grid { grid-template-columns: 1fr; } + .spec-row { grid-template-columns: repeat(2, 1fr); } .loc, .hub-card, .stat { grid-template-columns: 1fr; } .grid-days { grid-template-columns: repeat(2, 1fr); } .book-page { padding: 20px 16px 40px; } diff --git a/sites/webmd_doctor/templates/_mini_card.html b/sites/webmd_doctor/templates/_mini_card.html index 3a54fff6..4b651994 100644 --- a/sites/webmd_doctor/templates/_mini_card.html +++ b/sites/webmd_doctor/templates/_mini_card.html @@ -7,6 +7,6 @@ {% with rating=doctor.avg_rating, small=true %}{% include "_stars.html" %}{% endwith %} ({{ doctor.ratings_count | plural_word('Rating') }}) -
{{ doctor.years_experience }} Years Exp{% if doctor.awards %} · {{ doctor.awards | length | plural_word('Award') }}{% endif %}
- View Profile +
{{ doctor.years_experience }} Years Exp{% if doctor.awards %} {{ doctor.awards | length | plural_word('Award') }}{% endif %}
+ {% if filled %}View Profile{% else %}View Profile{% endif %} diff --git a/sites/webmd_doctor/templates/award_recipients.html b/sites/webmd_doctor/templates/award_recipients.html index 45c1ea4a..9dcf6984 100644 --- a/sites/webmd_doctor/templates/award_recipients.html +++ b/sites/webmd_doctor/templates/award_recipients.html @@ -5,12 +5,23 @@

{{ class_title }} Award 2025–2026

WebMD Choice Awards let you find the providers recognized by patients and health care professionals in the 2025–2026 cycle. Open a provider's profile for their full details.

-
Filters: +
Filters: + + {% for key, value in award_classes.items() %}{{ value[2] }}{% if key == award_class %} ✕{% endif %}{% endfor %} -
- +
- {% for doctor in page.rows %}{% include "_physician_card.html" %}{% endfor %} + {% set ns = namespace(state=None) %} + {% for doctor in page.rows %} + {% set state_name = doctor.primary_location.city.state_name %} + {% if state_name != ns.state %}{% set ns.state = state_name %}

{{ state_name }}

{% endif %} + {% include "_physician_card.html" %} + {% else %} +
No recipients match these filters.
+ {% endfor %}
{% include "_pagination.html" %} diff --git a/sites/webmd_doctor/templates/awards.html b/sites/webmd_doctor/templates/awards.html index 3d306e10..58eeb3e4 100644 --- a/sites/webmd_doctor/templates/awards.html +++ b/sites/webmd_doctor/templates/awards.html @@ -21,18 +21,24 @@

Best Hospitals According to Patients & Health Care Providers

Methodology Matters

You need information that you can trust when seeking care. The WebMD Choice Awards program is the only healthcare recognition program based solely on the vote of patients and providers within the last year. That's it. No complicated formulas here. Because we believe that finding best-in-class care should be easy.

-
+
+ +

Specialty Awards

The WebMD Choice Awards recognizes providers who deliver superior care in key specialties. Click the links below to view award recipients by specialty.

+
-
+
+ +

Awards by Class

Providers receive one of three distinct awards based on their WebMD Choice Awards ranking: WebMD Elite Choice for the providers preferred by patients and physicians two-to-one over competitors in their local market, WebMD Patient's Choice for providers in the top 30% of patient preferences, and Medscape Provider Choice for providers in the top 30% of health care provider preferences.

diff --git a/sites/webmd_doctor/templates/doctor.html b/sites/webmd_doctor/templates/doctor.html index 851ba7de..65703cce 100644 --- a/sites/webmd_doctor/templates/doctor.html +++ b/sites/webmd_doctor/templates/doctor.html @@ -7,6 +7,7 @@ WebMD CareProvidersSpecialties{{ doctor.primary_specialty.name }}{{ primary.city.state }}{{ primary.city.name }}{{ doctor.display_name }} +
@@ -45,8 +46,9 @@

{{ doctor.display_name }}

{% if doctor.hospital %}{% endif %} -
+ {% if doctor.is_enhanced %} +
-
September 2026
+
September 2026
{% for abbr, short, day, label in booking_days %}
@@ -69,31 +71,16 @@

Book an Appointment

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

{{ rail_title }}

-
- {% for colleague in colleagues %} -
- -
- -
{{ colleague.primary_specialty.name }}
{{ colleague.primary_location.city.name }}, {{ colleague.primary_location.city.state }}
-
{% with rating=colleague.avg_rating, small=true %}{% include "_stars.html" %}{% endwith %} ({{ colleague.ratings_count }})
-
-
- {% else %} -

No other providers listed.

- {% endfor %} -
- {% endif %} - + {% endif %} +
@@ -180,8 +167,9 @@

Patients' Perspective

-
- {% for n in (1, 2, 3, 4, 5) %}{% endfor %} +
+ {% for n in (1, 2, 3, 4, 5) %}{% endfor %} + Overall provider rating*
@@ -204,7 +192,7 @@

Patients' Perspective

{% endif %} -

{{ doctor.text_review_count }} REVIEWS

Most Recent
+

{{ doctor.text_review_count }} REVIEWS

Most Recent FilterSearch
Showing {{ reviews_page.start }}-{{ reviews_page.end }} of {{ reviews_page.total }} reviews
{% for review in reviews_page.rows %}
@@ -272,7 +260,7 @@

Patients' Perspective

{% if doctor.ratings_count %}

Patients' Perspective

-
    {% for label in doctor.perspective_summary() %}
  • {{ label }}
  • {% endfor %}

Based on patient feedback. Read the reviews

+
    {% for label in doctor.perspective_summary() %}
  • {{ label }}
  • {% endfor %}

Based on patient feedback. Read the reviews

{% endif %} @@ -317,14 +305,11 @@

Education & Training

{% if residencies %}
RESIDENCY

Completed their residency at {% for row in residencies %}{{ row.institution }} in {{ row.year }}{{ ' and ' if not loop.last else '.' }}{% endfor %}

{% endif %}
MEDICAL SCHOOL

Graduated from {{ doctor.medical_school }} in {{ doctor.graduation_year }}.

+

NPI Number

+

{{ doctor.short_name }}'s NPI number is {{ doctor.npi }}.

-
-

NPI Number

-

{{ doctor.short_name }}'s NPI number is {{ doctor.npi }}.

-
-

Languages Spoken

    {% for language in doctor.language_names %}
  • {{ language }}
  • {% endfor %}
@@ -358,45 +343,64 @@
MEDICAL SCHOOL
+ {% if doctor.is_enhanced %}
-

Other {{ doctor.primary_specialty.plural }} Nearby

-
-
- -
-

Data Transparency and Trust: Understanding WebMD Doctor Listings and Reviews

+

{{ rail_title }}

-

We know that finding the right doctor or provider is important to your health. That's why we want to ensure you have confidence in the provider profiles and listings you see on WebMD Care. Provider data in this mirror is synthetic benchmark data; on the live site it is sourced from physicians themselves and publicly available databases.

-

All the physician and provider reviews on WebMD Care are provided by users just like you. Providers are not able to remove or modify reviews on their own.

+
+ {% for colleague in colleagues[:6] %} +
+ + +
{{ colleague.primary_specialty.name }}
+
{{ colleague.years_experience }} Years Experience
+ {% with rating=colleague.avg_rating, small=true %}{% include "_stars.html" %}{% endwith %} +
+ {% else %}

No other providers listed.

{% endfor %} +
+ {% if primary.practice %}

View More Providers ›

{% endif %}
+ {% elif doctor.hospital %} +
+

Hospital Affiliations for {{ doctor.short_name }}

+

{{ doctor.hospital.name }}
{{ doctor.hospital.street }}, {{ doctor.hospital.city.name }}, {{ doctor.hospital.city.state }} {{ doctor.hospital.zip }}

+
+ {% endif %}
+ +
+

Other {{ doctor.primary_specialty.plural }} Nearby

+ +
+
+

Data Transparency and Trust: Understanding WebMD Doctor Listings and Reviews

+

We know that finding the right doctor or provider is important to your health. That's why we want to ensure you have confidence in the provider profiles and listings you see on WebMD Care. Provider data in this mirror is synthetic benchmark data; on the live site it is sourced from physicians themselves and publicly available databases.

+

All the physician and provider reviews on WebMD Care are provided by users just like you. Providers are not able to remove or modify reviews on their own.

+
{% endblock %} diff --git a/sites/webmd_doctor/templates/guidelines.html b/sites/webmd_doctor/templates/guidelines.html index 4754cec8..172470c4 100644 --- a/sites/webmd_doctor/templates/guidelines.html +++ b/sites/webmd_doctor/templates/guidelines.html @@ -2,25 +2,20 @@ {% block title %}Reviews Guidelines{% endblock %} {% block content %}
-
-

Reviews Guidelines

-

Reviews on WebMD Care are written by patients and shared to help other patients choose a provider. To keep them useful and fair, every review submitted on this site follows the rules below. Reviews that do not follow them are held for moderation and may be removed.

-

What to include

-
    -
  • Your own experience with the provider: how the visit went, whether your questions were answered, wait time and follow-up.
  • -
  • An overall star rating from 1 to 5, and a "did well" or "needs improvement" answer for each of the seven Patients' Perspective criteria.
  • -
  • At least 20 characters of written feedback so other patients understand the rating.
  • -
-

What to leave out

-
    -
  • Personal health information about other people, or the full names of office staff.
  • -
  • Profanity, threats, discriminatory language or advertising.
  • -
  • Reviews of a provider you have not seen, or reviews written on behalf of a provider.
  • -
-

How reviews are handled

-

A newly submitted review appears on the provider's profile to its author with the status Pending review until it is checked by the moderation team. Published ratings and counts on a profile do not change until a review is approved. Providers cannot edit or remove reviews; they may reply to a published review through their claimed profile.

-

Why you can trust the reviews on WebMD Care

-

Every review is tied to a registered account, screened for the rules above, and displayed with its submission date. Helpful votes and flags from other readers are used to prioritise moderation.

+
+

WebMD Reviews Guidelines

+

By submitting a review on the WebMD Care physician directory you agree to the Terms and Conditions and to the following review guidelines:

+
    +
  1. Your review must describe your own experience with the provider: how the visit went, whether your questions were answered, the wait time and the follow-up. Reviews of a provider you have not seen, or written on behalf of a provider, are not accepted.
  2. +
  3. Your review must not include personal health information about other people, or the full names of office staff.
  4. +
  5. Your review must not contain profanity, threats, discriminatory language or anything unlawful, defamatory or harassing toward any person or entity.
  6. +
  7. Your review must not include advertisements, solicitations or "spam" that detracts from other patients' experience of the site.
  8. +
  9. Your review must carry an overall star rating from 1 to 5 and a "did well" or "needs improvement" answer for each of the seven Patients' Perspective criteria.
  10. +
  11. Your review must contain at least 20 characters of written feedback so other patients understand the rating.
  12. +
  13. A newly submitted review appears on the provider's profile to its author with the status Pending review until the moderation team checks it; published ratings and counts on a profile do not change until a review is approved.
  14. +
  15. Providers cannot edit or remove reviews. They may reply to a published review through their claimed profile, and readers' helpful votes and flags are used to prioritise moderation.
  16. +
+

WebMD Care reserves the right, in its sole discretion, to remove reviews that do not meet these guidelines or the terms set forth in the Terms and Conditions.

{% endblock %} diff --git a/sites/webmd_doctor/templates/hospital.html b/sites/webmd_doctor/templates/hospital.html index 22b2c4a7..bdcdfdaf 100644 --- a/sites/webmd_doctor/templates/hospital.html +++ b/sites/webmd_doctor/templates/hospital.html @@ -17,6 +17,14 @@

Physicians At {{ hospital.name }}

Showing {{ page.start }}-{{ page.end }} of {{ page.total }} Physicians
+

Select a specialty below to view hospital physicians by specialty. Includes featured providers.

+
+ + +
{% for doctor in page.rows %}
@@ -34,8 +42,8 @@

Specialties

{{ doctors | length }} practicing physicians across {{ specialty_rows | length }} specialties are affiliated with this hospital.

    {% for name, count in specialty_rows %}
  • {{ name }} ({{ count }})
  • {% endfor %}
-

Locations

{{ hospital.name }}
{{ hospital.street }}
{{ hospital.city.name }}, {{ hospital.city.state }} {{ hospital.zip }}
Map unavailable in mirror
-

Ratings And Reviews

{{ hospital.name }} Rating
{% with rating=hospital.avg_rating %}{% include "_stars.html" %}{% endwith %} {{ hospital.ratings_count | plural_word('Rating') }}
Reviews are written on provider profiles
+

Locations

Map unavailable in mirror
{{ hospital.name }}
{{ hospital.street }}
{{ hospital.city.name }}, {{ hospital.city.state }} {{ hospital.zip }}
Get Directions
+

Ratings And Reviews

{{ hospital.name }} Rating
{% with rating=hospital.avg_rating %}{% include "_stars.html" %}{% endwith %} {{ hospital.ratings_count | plural_word('Rating') }}
Reviews are written on provider profiles
Write a Review

Patient Satisfaction Poll

{% for row in poll_rows %}
{{ row.question }}
YesNo
Yes ({{ row.yes }})No ({{ row.no }})
{% endfor %}

Frequently Asked Questions

Where is {{ hospital.name }} located?
{{ hospital.name }} is located at {{ hospital.street }}, {{ hospital.city.name }}, {{ hospital.city.state }}, {{ hospital.zip }}.
diff --git a/sites/webmd_doctor/templates/hub_index.html b/sites/webmd_doctor/templates/hub_index.html new file mode 100644 index 00000000..1a2d47f5 --- /dev/null +++ b/sites/webmd_doctor/templates/hub_index.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}Find The Best {{ title }} Near You{% endblock %} +{% block content %} +
+ +

Find The Best {{ title }} Near You

+

There are {{ total }} {{ title }} for you to review across {{ states | length }} states and territories.

+
+ {% for state in states %}{{ state.name }} ({{ state.count }}){% endfor %} +
+ +

Top {{ title }} {{ 'in' if kind == 'hospitals' else 'near' }} {{ anchor.state_name }}

+
+ {% for item in top_items %} + {% set detail = url_for('hospital_detail', slug=item.slug) if kind == 'hospitals' else url_for('practice_detail', slug=item.slug) %} +
+

{{ item.name }}

+
{{ counts[item.id].specialties | plural_word('Specialty', 'Specialties') }}, {{ counts[item.id].physicians | plural_word('Practicing Physician') }}
+
{% with rating=item.avg_rating, small=true %}{% include "_stars.html" %}{% endwith %} ({{ item.ratings_count | plural_word('Rating') }})
+ View Profile +
+ {% endfor %} +
+

Search All {{ title }} in {{ anchor.state_name }}

+ {% if kind == 'hospitals' %} +

Find Award-Winning Hospitals by Type of Care

+
+ {% for label, spec_slug in care_types %}{{ label }}{% endfor %} +
+ {% else %} +
+ Find Doctors › + +
+ {% endif %} +
+{% endblock %} diff --git a/sites/webmd_doctor/templates/hub_list.html b/sites/webmd_doctor/templates/hub_list.html index edc10a08..4ace6a9f 100644 --- a/sites/webmd_doctor/templates/hub_list.html +++ b/sites/webmd_doctor/templates/hub_list.html @@ -2,15 +2,12 @@ {% block title %}{{ title }}{% if state_name %} in {{ state_name }}{% endif %}{% endblock %} {% block content %}
- -

{{ title }}{% if state_name %} in {{ state_name }}{% endif %}

+ +

{{ title }} in {{ state_name }}

There are {{ total }} {{ title }} for you to review across {{ city_count }} cities and towns.

- {% if not state_name %} -
- {% for state in states %}{{ state.name }} ({{ state.count }}){% endfor %} -
- {% endif %} + {% if name_filter %}

Showing {{ title | lower }} whose name contains “{{ name_filter }}”. Clear

{% endif %}
+ {% if name_filter %}{% endif %}
@@ -25,7 +22,6 @@

{{ title }}{% if state_name %} in {{ state_name }}{% endi {% for n in (5, 4, 3, 2, 1) %}{% endfor %}

-
diff --git a/sites/webmd_doctor/templates/index.html b/sites/webmd_doctor/templates/index.html index d410308c..da7c4902 100644 --- a/sites/webmd_doctor/templates/index.html +++ b/sites/webmd_doctor/templates/index.html @@ -32,8 +32,8 @@

Choose the healthcare
that is right for you

Popular specialties

-
- {% for specialty in specialties %}{{ specialty.singular }}{% endfor %} +
+ {% for specialty in specialties %}{{ specialty.singular }}{% endfor %}
@@ -42,10 +42,10 @@

Popular specialties

Top Doctors Near
Newark, DE
- {% for doctor in top_doctors %}{% include "_mini_card.html" %}{% endfor %} + {% for doctor in top_doctors %}{% with filled=true %}{% include "_mini_card.html" %}{% endwith %}{% endfor %}
-

FIND YOUR DOCTOR

-

Physicians: Claim Your Profile ›

+

FIND YOUR DOCTOR

+

Physicians: Claim Your Profile ›

diff --git a/sites/webmd_doctor/templates/practice.html b/sites/webmd_doctor/templates/practice.html index 6e1ddca1..ed99e8ca 100644 --- a/sites/webmd_doctor/templates/practice.html +++ b/sites/webmd_doctor/templates/practice.html @@ -16,6 +16,7 @@

Physicians At {{ practice.name }}

+

Includes featured providers.

Showing {{ page.start }}-{{ page.end }} of {{ page.total }} Physicians
{% for doctor in page.rows %} @@ -37,19 +38,20 @@

Specialties

{{ doctors | length }} practicing physicians across {{ specialty_rows | length }} specialties are affiliated with this practice.

    {% for name, count in specialty_rows %}
  • {{ name }} ({{ count }})
  • {% endfor %}

Insurance Plans Accepted ({{ insurers | length }})

Please verify insurance information with your doctor's office as it may change frequently.

    {% for name in insurers %}
  • {{ name }}
  • {% endfor %}

Locations

-
-
{{ practice.name }}
-
{{ practice.street }}
{{ practice.city.name }}, {{ practice.city.state }} {{ practice.zip }}
- - -
- {{ 'Accepts' if flags.medicare else 'Does not accept' }} Medicare - {{ 'Accepts' if flags.medicaid else 'Does not accept' }} Medicaid - {{ 'Accepting' if flags.new_patients else 'Not accepting' }} new patients +
Map unavailable in mirror
+
{{ practice.name }}
+
{{ practice.street }}
{{ practice.city.name }}, {{ practice.city.state }} {{ practice.zip }}
Get Directions
+ + +
+
Accepting New Patients: {{ 'Yes' if flags.new_patients else 'No' }}
+
Medicare Accepted: {{ 'Yes' if flags.medicare else 'No' }}
+
Medicaid Accepted: {{ 'Yes' if flags.medicaid else 'No' }}
{% for row in practice.hours_rows() %}
{{ row.day }}
{{ row.text }}
{% endfor %}
-
Map unavailable in mirror
+
+

Ratings And Reviews

{{ practice.name }} Rating
{% with rating=practice.avg_rating %}{% include "_stars.html" %}{% endwith %} {{ practice.ratings_count | plural_word('Rating') }}
Reviews are written on provider profiles
Write a Review

Frequently Asked Questions

Where is {{ practice.name }} located?
{{ practice.name }} is located at {{ practice.street }}, {{ practice.city.name }}, {{ practice.city.state }}, {{ practice.zip }}.
What is {{ practice.name }}'s phone number?
The contact number for {{ practice.name }} is {{ practice.phone }}.
diff --git a/sites/webmd_doctor/templates/signup.html b/sites/webmd_doctor/templates/signup.html index 85a62c1d..c1519d90 100644 --- a/sites/webmd_doctor/templates/signup.html +++ b/sites/webmd_doctor/templates/signup.html @@ -16,9 +16,10 @@ - - + + +

By signing up, I agree to WebMD Terms of Use & Privacy Policy. I understand that I may opt out of WebMD subscriptions at any time.

diff --git a/sites/webmd_doctor/templates/specialty_city.html b/sites/webmd_doctor/templates/specialty_city.html index dd3f506d..873d22c1 100644 --- a/sites/webmd_doctor/templates/specialty_city.html +++ b/sites/webmd_doctor/templates/specialty_city.html @@ -7,7 +7,7 @@

Best {{ specialty.

{{ city.name }}, {{ city.state }} has {{ total }} {{ specialty.singular }} results with an average of {{ average_experience }} years of experience and a total of {{ total_reviews }} ratings. Currently, {{ accepting }} providers have noted they are accepting new patients. Conditions treated by {{ specialty.plural }} often include {{ conditions | map(attribute='name') | join(', ') }}. Some common procedures performed by {{ specialty.plural }} include {{ procedures | map(attribute='name') | join(', ') }}.

{% include "_filter_bar.html" %}
WebMD PATIENT'S CHOICEPatients' Choice awards are assigned based on patient satisfaction ratings for key specialties in select geographic locations.
- + {% if page.rows %}
{% for row in page.rows %}{% with doctor=row.doctor %}{% include "_physician_card.html" %}{% endwith %}{% endfor %} diff --git a/sites/webmd_doctor/templates/specialty_landing.html b/sites/webmd_doctor/templates/specialty_landing.html index 670fcf3e..9ad61fc8 100644 --- a/sites/webmd_doctor/templates/specialty_landing.html +++ b/sites/webmd_doctor/templates/specialty_landing.html @@ -4,9 +4,9 @@

Best {{ specialty.plural }} in the United States

-

There are {{ total }} {{ specialty.plural }} for you to review across {{ states | length }} states. {{ specialty.description }}

+

There are {{ total }} {{ specialty.plural }} for you to review across {{ states | length }} states and territories

- {% for state in states %}{{ state.name }} ({{ state.count }}){% endfor %} + {% for state in states %}{{ state.name }}{% endfor %}

Highest Rated {{ specialty.plural }} near {{ anchor.name }}, {{ anchor.state }}

diff --git a/sites/webmd_doctor/templates/specialty_state.html b/sites/webmd_doctor/templates/specialty_state.html index 75aaa382..76ca0e5a 100644 --- a/sites/webmd_doctor/templates/specialty_state.html +++ b/sites/webmd_doctor/templates/specialty_state.html @@ -4,12 +4,12 @@

Best {{ specialty.plural }} in {{ state_name }}

-

{{ state_name }} has {{ total }} {{ specialty.singular }} results across {{ cities | length }} cities. Choose a city or filter the full list below.

+

There are {{ total }} {{ specialty.plural }} for you to review across {{ cities | length }} cities and towns

{% for city in cities %}{{ city.name }} ({{ city.count }}){% endfor %}
{% include "_filter_bar.html" %} - +
{% for row in page.rows %}{% with doctor=row.doctor %}{% include "_physician_card.html" %}{% endwith %}{% endfor %}
From 0bbd841c2c8e3f003e700a43d5df52b3eacdf787 Mon Sep 17 00:00:00 2001 From: evanz37 <89922710+evanz37@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:06:08 -0400 Subject: [PATCH 05/21] fix(webmd_doctor): finalize deterministic build-generated seed README row counts match EXPECTED_COUNTS of the final seed (226 doctors, 348 locations, 91 posters). Freezer byte-identical across two runs and PYTHONHASHSEED=0/1; in-image instance == instance_seed before and after docker restart. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GTvmqJcShyy3v3KfEPxMPe --- sites/webmd_doctor/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/sites/webmd_doctor/README.md b/sites/webmd_doctor/README.md index 35c4018f..72463797 100644 --- a/sites/webmd_doctor/README.md +++ b/sites/webmd_doctor/README.md @@ -11,18 +11,18 @@ cd sites/webmd_doctor && PYTHONHASHSEED=0 ../../.venv/bin/python seed_data.py PORT=40024 ../../.venv/bin/python app.py ``` -The Docker build regenerates `instance_seed/webmd_doctor.db` plus the Pillow avatars (224) and video poster frames (90) from `seed_data.py`; the site ships no Hugging Face assets (`.build-generated-seed`). `seed_metadata` version `webmd-doctor-v1`, `EXPECTED_COUNTS` and a foreign-key check reject partial or incompatible state, and every seed function is gated as a whole so `/reset/webmd_doctor` and `docker restart` leave the DB byte-identical. +The Docker build regenerates `instance_seed/webmd_doctor.db` plus the Pillow avatars (226) and video poster frames (91) from `seed_data.py`; the site ships no Hugging Face assets (`.build-generated-seed`). `seed_metadata` version `webmd-doctor-v1`, `EXPECTED_COUNTS` and a foreign-key check reject partial or incompatible state, and every seed function is gated as a whole so `/reset/webmd_doctor` and `docker restart` leave the DB byte-identical. ## Seeded rows | Model | Rows | Model | Rows | |---|---|---|---| -| doctors | 224 (200 within 40 mi of Newark, DE 19711 + 24 in Baltimore, MD) | locations | 345 | +| doctors | 226 (202 within 40 mi of Newark, DE 19711 + 24 in Baltimore, MD) | locations | 348 | | specialties | 10 | conditions / procedures / expertise_areas | 40 / 30 / 40 | -| doctor_conditions / doctor_procedures / doctor_expertise | 1619 / 1121 / 674 | insurers / insurance_plans / doctor_insurances | 12 / 28 / 2224 | +| doctor_conditions / doctor_procedures / doctor_expertise | 1677 / 1252 / 686 | insurers / insurance_plans / doctor_insurances | 12 / 28 / 2274 | | cities / city_zips | 8 / 24 | hospitals / practices | 12 / 30 | -| reviews | 1208 | doctor_perspectives | 1568 | -| certifications / licenses / education | 286 / 301 / 573 | awards / doctor_languages | 50 / 327 | +| reviews | 1206 | doctor_perspectives | 1582 | +| certifications / licenses / education | 296 / 316 / 567 | awards / doctor_languages | 50 / 351 | | users | 4 | saved_providers / appointment_requests / user_reviews | 4 / 1 / 1 | Benchmark accounts: `alice.j`, `bob.c`, `carol.d`, `david.k` `@test.com`, password `TestPass123!`. From 00365d146894c14486e04be285da5233c9e2833c Mon Sep 17 00:00:00 2001 From: evanz37 <89922710+evanz37@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:07:40 -0400 Subject: [PATCH 06/21] docs: bump site count to 25 (ports 40000-40024) Shared docs (README, AGENTS, CONTRIBUTING, CLAUDE, agent_demo/README) and the registry assertions in the walmart_careers and rotten_tomatoes tests now cover the appended webmd_doctor slot (index 24, port 40024). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GTvmqJcShyy3v3KfEPxMPe --- AGENTS.md | 12 ++++++------ CLAUDE.md | 2 +- CONTRIBUTING.md | 2 +- README.md | 6 +++--- agent_demo/README.md | 2 +- .../tests/test_environment_quality.py | 4 ++-- sites/walmart_careers/README.md | 2 +- sites/walmart_careers/tests/test_integration.py | 13 +++++++------ 8 files changed, 22 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 22696c27..3c84c3b2 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 -23 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. +25 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-40023:40000-40023 webharbor:dev +docker run -d -p 8101:8101 -p 40000-40024:40000-40024 webharbor:dev ``` Or use the published image directly: ```bash -docker run -d -p 8101:8101 -p 40000-40023:40000-40023 \ +docker run -d -p 8101:8101 -p 40000-40024:40000-40024 \ battalion7244/webharbor:latest ``` -Sites are on `40000`-`40023` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: +Sites are on `40000`-`40024` 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-41023:40000-40023 webharbor:dev + -p 8201:8101 -p 41000-41024:40000-40024 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 41023); do +for p in $(seq 41000 41024); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done diff --git a/CLAUDE.md b/CLAUDE.md index 12e3cb0f..ad0e2fd0 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-40023`, 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-41023`). +If a container is already running on `:8101` / `:40000-40024`, 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-41024`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f36384c..1029fa9b 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-40023:40000-40023 webharbor:dev + -p 8101:8101 -p 40000-40024:40000-40024 webharbor:dev # iterate locally... ./scripts/extract_assets.sh ../webharbor-static-pr/ # split assets out diff --git a/README.md b/README.md index a05d266a..5fcf1129 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** — 24 sites today, scaling to 100+ together +- **Community-driven** — 25 sites today, scaling to 100+ together ## 🚀 Quickstart One command to run all web environments: ```bash -docker run -p 8101:8101 -p 40000-40023:40000-40023 battalion7244/webharbor:latest +docker run -p 8101:8101 -p 40000-40024:40000-40024 battalion7244/webharbor:latest ``` -Then point your agent at `http://localhost:40000` through `http://localhost:40023` to explore 24 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, Target, TED, Ohio State University, Rotten Tomatoes, Compass, and Walmart Careers`. +Then point your agent at `http://localhost:40000` through `http://localhost:40024` to explore 25 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, Target, TED, Ohio State University, Rotten Tomatoes, Compass, Walmart Careers, and WebMD Doctor`. 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 44c25e60..c3d75c28 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-40023:40000-40023 battalion7244/webharbor:latest`). +WebHarbor must already be running locally (`docker run -p 8101:8101 -p 40000-40024:40000-40024 battalion7244/webharbor:latest`). Run a single task from a site's `tasks.jsonl`: diff --git a/sites/rotten_tomatoes/tests/test_environment_quality.py b/sites/rotten_tomatoes/tests/test_environment_quality.py index 806af708..8992a939 100644 --- a/sites/rotten_tomatoes/tests/test_environment_quality.py +++ b/sites/rotten_tomatoes/tests/test_environment_quality.py @@ -88,9 +88,9 @@ def test_task_manifest_and_registry(self): self.assertEqual(row['web'], 'http://localhost:40021/') self.assertTrue((ROOT / row['verifier_path']).is_file()) self.assertNotIn('answer', row) - self.assertIn('ted osu rotten_tomatoes compass walmart_careers)', (ROOT / 'websyn_start.sh').read_text()) + self.assertIn('ted osu rotten_tomatoes compass walmart_careers', (ROOT / 'websyn_start.sh').read_text()) self.assertIn("'ted', 'osu', 'rotten_tomatoes'", (ROOT / 'control_server.py').read_text()) - self.assertIn('40000-40023', (ROOT / 'Dockerfile').read_text()) + self.assertIn('40000-40024', (ROOT / 'Dockerfile').read_text()) self.assertTrue((SITE / '.build-generated-seed').is_file()) self.assertTrue((SITE / '.requires-images').is_file()) self.assertTrue((SITE / '.requires-external-cache').is_file()) diff --git a/sites/walmart_careers/README.md b/sites/walmart_careers/README.md index 4241bc6a..cdf13fb4 100644 --- a/sites/walmart_careers/README.md +++ b/sites/walmart_careers/README.md @@ -1,6 +1,6 @@ # Walmart Careers mirror -This directory contains an offline Flask mirror modeled on `https://careers.walmart.com`. In the 24-site registry it runs on container port `40023`. All jobs, stores, requisition identifiers, user accounts, saved roles and applications are deterministic synthetic benchmark data. +This directory contains an offline Flask mirror modeled on `https://careers.walmart.com`. In the 25-site registry it runs on container port `40023`. All jobs, stores, requisition identifiers, user accounts, saved roles and applications are deterministic synthetic benchmark data. ## Runtime diff --git a/sites/walmart_careers/tests/test_integration.py b/sites/walmart_careers/tests/test_integration.py index 1a6d9f44..e01d6c7c 100644 --- a/sites/walmart_careers/tests/test_integration.py +++ b/sites/walmart_careers/tests/test_integration.py @@ -11,7 +11,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", - "target", "ted", "osu", "rotten_tomatoes", "compass", "walmart_careers", + "target", "ted", "osu", "rotten_tomatoes", "compass", "walmart_careers", "webmd_doctor", ] @@ -33,12 +33,13 @@ def test_exact_24_site_registry_and_port(): assert EXPECTED.index("rotten_tomatoes") + 40000 == 40021 assert EXPECTED.index("compass") + 40000 == 40022 assert EXPECTED.index("walmart_careers") + 40000 == 40023 + assert EXPECTED.index("webmd_doctor") + 40000 == 40024 def test_docker_preserves_current_main_build_gates_and_adds_walmart(): text = (ROOT / "Dockerfile").read_text() - assert "24 Flask mirror sites" in text - assert "EXPOSE 8101 40000-40023" in text + assert "25 Flask mirror sites" in text + assert "EXPOSE 8101 40000-40024" in text assert "check_asset_inventory.py /opt/WebSyn/compass" in text assert "check_asset_inventory.py /opt/WebSyn/walmart_careers" in text assert "walmart_careers/check_tracked_assets.py" in text @@ -65,11 +66,11 @@ def test_assets_pin_is_immutable_merged_revision(): assert (SITE / "tracked_asset_inventory.json").is_file() -def test_shared_documentation_uses_24_site_range(): +def test_shared_documentation_uses_25_site_range(): for relative in ["README.md", "AGENTS.md", "CONTRIBUTING.md", "CLAUDE.md", "agent_demo/README.md"]: text = (ROOT / relative).read_text() - assert "40000-40022" not in text, relative - assert "40000-40023" in text, relative + assert "40000-40023" not in text, relative + assert "40000-40024" in text, relative def test_no_merge_conflict_markers_in_release_files(): From a2b4c7fa4ff6adbab0ddaa5be23279073eadc9ae Mon Sep 17 00:00:00 2001 From: evanz37 <89922710+evanz37@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:54:51 -0400 Subject: [PATCH 07/21] fix(webmd_doctor): ship images via HF tarball (build-generated DB unchanged) Switch to the walmart_careers asset shape: the Docker RUN block still regenerates instance_seed/webmd_doctor.db deterministically from seed_data.py, but the 226 initials avatars and 91 video posters under static/images/ now arrive through the pinned Hugging Face tarball (.requires-images) instead of being written at image-build time. - seed_data.py: image generation moves behind `--write-images` / build_images() for the local freezer only; PNGs are saved with fixed compression and no ancillary chunks (IHDR/IDAT/IEND only), identical under PYTHONHASHSEED=0 and 1 (317 files, list digest e1b14d92). - Dockerfile: assert the fetched avatars/posters are present, then build the DB only. In-image seed md5 unchanged (364c15c35fff64f0c25ef62ac0bb64fc). - Markers: .build-generated-seed reworded, .requires-images added so check_assets.sh / fetch_assets.sh treat the site like walmart_careers; .assetpaths globs already cover static/images/. scripts/ untouched. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Qq2aHgZwwYWAS7cCepWKiu --- Dockerfile | 6 ++- sites/webmd_doctor/.build-generated-seed | 2 +- sites/webmd_doctor/.requires-images | 1 + sites/webmd_doctor/README.md | 5 ++- sites/webmd_doctor/seed_data.py | 51 +++++++++++++++++++----- 5 files changed, 50 insertions(+), 15 deletions(-) create mode 100644 sites/webmd_doctor/.requires-images diff --git a/Dockerfile b/Dockerfile index db903c66..f62999d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,8 +50,10 @@ RUN python3 /opt/check_asset_inventory.py /opt/WebSyn/walmart_careers && \ RUN cd /opt/WebSyn/walmart_careers && rm -rf instance instance_seed && \ PYTHONHASHSEED=0 python seed_data.py && rm -rf instance -# WebMD Doctor ships no HF assets: the deterministic SQLite seed, the Pillow -# initials avatars and the video poster frames are all generated here. +# WebMD Doctor's generated avatars / posters come from the pinned asset bundle, +# while its SQLite seed is rebuilt deterministically from tracked source code. +RUN test -n "$(ls -A /opt/WebSyn/webmd_doctor/static/images/avatars)" && \ + test -n "$(ls -A /opt/WebSyn/webmd_doctor/static/images/posters)" RUN cd /opt/WebSyn/webmd_doctor && rm -rf instance instance_seed && \ PYTHONHASHSEED=0 python seed_data.py && rm -rf instance diff --git a/sites/webmd_doctor/.build-generated-seed b/sites/webmd_doctor/.build-generated-seed index c799e8e3..7f8edd50 100644 --- a/sites/webmd_doctor/.build-generated-seed +++ b/sites/webmd_doctor/.build-generated-seed @@ -1 +1 @@ -The Dockerfile generates instance_seed/webmd_doctor.db, static/images/avatars/ and static/images/posters/ deterministically from tracked source code (seed_data.py); this site has no Hugging Face assets. +The Dockerfile generates instance_seed/webmd_doctor.db deterministically from tracked source code (seed_data.py); static/images/ ships from the pinned Hugging Face archive. diff --git a/sites/webmd_doctor/.requires-images b/sites/webmd_doctor/.requires-images new file mode 100644 index 00000000..d0aa0ba4 --- /dev/null +++ b/sites/webmd_doctor/.requires-images @@ -0,0 +1 @@ +WebMD Doctor requires its generated initials avatars and video poster frames (static/images/{avatars,posters}/) from the pinned Hugging Face archive. diff --git a/sites/webmd_doctor/README.md b/sites/webmd_doctor/README.md index 72463797..e72f0a0e 100644 --- a/sites/webmd_doctor/README.md +++ b/sites/webmd_doctor/README.md @@ -7,11 +7,12 @@ Offline Flask mirror of `https://doctor.webmd.com/` (branded "WebMD Care" upstre ```bash uv venv .venv --python 3.12 uv pip install --python .venv/bin/python -r sites/webmd_doctor/requirements.txt -cd sites/webmd_doctor && PYTHONHASHSEED=0 ../../.venv/bin/python seed_data.py # writes instance_seed/, static/images/{avatars,posters}/ +./scripts/fetch_assets.sh webmd_doctor # static/images/{avatars,posters}/ from the HF tarball +cd sites/webmd_doctor && PYTHONHASHSEED=0 ../../.venv/bin/python seed_data.py # writes instance_seed/webmd_doctor.db PORT=40024 ../../.venv/bin/python app.py ``` -The Docker build regenerates `instance_seed/webmd_doctor.db` plus the Pillow avatars (226) and video poster frames (91) from `seed_data.py`; the site ships no Hugging Face assets (`.build-generated-seed`). `seed_metadata` version `webmd-doctor-v1`, `EXPECTED_COUNTS` and a foreign-key check reject partial or incompatible state, and every seed function is gated as a whole so `/reset/webmd_doctor` and `docker restart` leave the DB byte-identical. +The Docker build regenerates `instance_seed/webmd_doctor.db` from `seed_data.py` (`.build-generated-seed`); the Pillow initials avatars (226) and video poster frames (91) under `static/images/` ship in the pinned Hugging Face tarball (`.requires-images`) and are regenerated locally with `PYTHONHASHSEED=0 python seed_data.py --write-images` (byte-stable PNGs: fixed compression, no ancillary chunks). `seed_metadata` version `webmd-doctor-v1`, `EXPECTED_COUNTS` and a foreign-key check reject partial or incompatible state, and every seed function is gated as a whole so `/reset/webmd_doctor` and `docker restart` leave the DB byte-identical. ## Seeded rows diff --git a/sites/webmd_doctor/seed_data.py b/sites/webmd_doctor/seed_data.py index 4fa2748b..5d5e5a14 100644 --- a/sites/webmd_doctor/seed_data.py +++ b/sites/webmd_doctor/seed_data.py @@ -1,9 +1,12 @@ """Deterministic seed for the WebMD Doctor mirror. Run directly (`PYTHONHASHSEED=0 python seed_data.py`) to rebuild -`instance_seed/webmd_doctor.db`, `static/images/avatars/*.png` and -`static/images/posters/*.png`. Byte-reproducible: one seeded RNG, no -wall-clock reads, sorted iteration only, hard-coded password hashes. +`instance_seed/webmd_doctor.db` (this is what the Docker build does). +`PYTHONHASHSEED=0 python seed_data.py --write-images` additionally regenerates +`static/images/avatars/*.png` and `static/images/posters/*.png`; those PNGs +ship through the pinned Hugging Face asset tarball, never from the image +build. Byte-reproducible: one seeded RNG, no wall-clock reads, sorted +iteration only, hard-coded password hashes, PNGs without ancillary chunks. Every doctor, practice, hospital, address, phone, NPI, school and review is synthetic. Real city / state / specialty / insurer names are reused only as @@ -1267,7 +1270,9 @@ def ensure_seed_database() -> None: # --------------------------------------------------------------------------- # -# Generated images (deterministic PNG bytes, no text chunks) +# Generated images (deterministic PNG bytes, no text chunks). Local freezer +# only (`seed_data.py --write-images`): the Docker build never calls this; +# the PNGs are fetched from the pinned Hugging Face tarball. # --------------------------------------------------------------------------- # AVATAR_PALETTE = [ (0, 21, 124), (53, 87, 255), (14, 116, 144), (99, 64, 178), (27, 94, 32), (150, 63, 122), @@ -1281,6 +1286,11 @@ def _initials_font(size: int): return ImageFont.load_default(size=size) +def _save_png(image, path: Path) -> None: + # Fixed compression, no tIME / tEXt / zTXt chunks: the bytes depend only on the pixels. + image.save(path, format="PNG", optimize=False, compress_level=9, pnginfo=None) + + def write_images(doctors: list[Doctor]) -> None: from PIL import Image, ImageDraw @@ -1299,7 +1309,7 @@ def write_images(doctors: list[Doctor]) -> None: box = draw.textbbox((0, 0), initials, font=font_large) width, height = box[2] - box[0], box[3] - box[1] draw.text(((150 - width) / 2 - box[0], (150 - height) / 2 - box[1]), initials, fill=(255, 255, 255), font=font_large) - image.save(AVATAR_DIR / f"{doctor.slug}.png", format="PNG", optimize=True) + _save_png(image, AVATAR_DIR / f"{doctor.slug}.png") if doctor.is_enhanced: poster = Image.new("RGB", (640, 360), (0, 6, 37)) pdraw = ImageDraw.Draw(poster) @@ -1312,10 +1322,10 @@ def write_images(doctors: list[Doctor]) -> None: pdraw.text((320 - width / 2 - box[0], 180 - height / 2 - box[1]), initials, fill=(255, 255, 255), font=font_poster) pdraw.ellipse((560, 280, 620, 340), fill=(53, 87, 255)) pdraw.polygon([(582, 296), (582, 324), (606, 310)], fill=(255, 255, 255)) - poster.save(POSTER_DIR / f"{doctor.slug}.png", format="PNG", optimize=True) + _save_png(poster, POSTER_DIR / f"{doctor.slug}.png") -def build_seed_database() -> None: +def build_seed_database(write_images_too: bool = False) -> None: INSTANCE_SEED_DIR.mkdir(parents=True, exist_ok=True) DB_PATH.parent.mkdir(parents=True, exist_ok=True) destination = INSTANCE_SEED_DIR / "webmd_doctor.db" @@ -1331,7 +1341,8 @@ def build_seed_database() -> None: ensure_seed_database() if checks is not None: checks() - write_images(Doctor.query.order_by(Doctor.id).all()) + if write_images_too: + write_images(Doctor.query.order_by(Doctor.id).all()) db.session.remove() with db.engine.connect() as connection: connection.execute(text("VACUUM")) @@ -1347,6 +1358,26 @@ def build_seed_database() -> None: print("scripts_dev/assert_distractors.py not present - skipping the build-time task invariants.") +def build_images() -> None: + """Local freezer entry point: regenerate the avatar / poster PNGs from the seed.""" + with app.app_context(): + db.create_all() + ensure_seed_database() + write_images(Doctor.query.order_by(Doctor.id).all()) + db.session.remove() + db.engine.dispose() + + if __name__ == "__main__": - build_seed_database() - print("Seed database, avatars and posters generated for WebMD Doctor.") + import argparse + + parser = argparse.ArgumentParser(description="Rebuild the deterministic WebMD Doctor seed.") + parser.add_argument("--write-images", action="store_true", + help="also regenerate static/images/{avatars,posters}/ (local freezer only; " + "the Docker build ships them from the Hugging Face tarball)") + args = parser.parse_args() + build_seed_database(write_images_too=args.write_images) + if args.write_images: + print("Seed database, avatars and posters generated for WebMD Doctor.") + else: + print("Seed database generated for WebMD Doctor (images come from the HF asset tarball).") From a6ece6ea0f4761d8502cd26e982dfdf7645fd40b Mon Sep 17 00:00:00 2001 From: evanz37 <89922710+evanz37@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:01:19 -0400 Subject: [PATCH 08/21] chore(webmd_doctor): pin assets to HF dataset PR (ChilleD/WebHarbor discussions/68; temporary) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Qq2aHgZwwYWAS7cCepWKiu --- .assets-revision | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.assets-revision b/.assets-revision index fabc7ecf..361a0c3c 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: 65c479f894763f64c6073e0d180ebf542d1d2c02 +revision: refs/pr/68 From 434b3cca5fd47e7f8b74095561282d530c348e33 Mon Sep 17 00:00:00 2001 From: evanz37 <89922710+evanz37@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:58:50 -0400 Subject: [PATCH 09/21] =?UTF-8?q?fix(webmd=5Fdoctor):=20audit=20B=20harden?= =?UTF-8?q?ing=20=E2=80=94=20payer-row=20consistency,=20unmatched-search?= =?UTF-8?q?=20notice,=20city-page=20sections,=20profile=20scale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - seed: Medicare/Medicaid Insurance-card rows now follow the primary office flags (deterministic post-pass, no RNG; doctor_insurances 2274 -> 2208; in-image md5 8bf76b696f8e49ba59848a51bf47f24a; avatars/posters byte-identical, no HF change) - results: unresolved search text keeps the "ignore" semantics but the heading reads "All Providers" and a mirror-specific notice names the unmatched input - specialty city page: description blurb, FAQ and related-specialties grid below the list (FAQ removed from the landing page), Distance control + distance on cards - profile: type scale matched to upstream, sticky tabs + compact provider bar on scroll, "View Less" toggle, certification-card dividers, grey nearby/transparency panels - chrome: blue filter-pill labels, banner dismiss, larger stars, inline View Profile on regular cards, grey current page, footer badge cluster + privacy toggle, hub hero / Sort By / info-block styling, extra hospital & practice FAQ entries, recipients show only the active award chip, awards sub-nav chevrons, guidelines page without search row/footer, styled booking select, auth overlay dims the header Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QGf8U2ULBCevvaYfbBBuVt --- sites/webmd_doctor/README.md | 2 +- sites/webmd_doctor/app.py | 20 ++- sites/webmd_doctor/seed_data.py | 23 +++- sites/webmd_doctor/static/css/site.css | 122 ++++++++++++------ sites/webmd_doctor/static/js/site.js | 29 +++++ sites/webmd_doctor/templates/_footer.html | 9 +- .../templates/_physician_card.html | 2 +- .../templates/award_recipients.html | 2 +- sites/webmd_doctor/templates/awards.html | 4 +- sites/webmd_doctor/templates/base.html | 2 +- sites/webmd_doctor/templates/doctor.html | 20 ++- sites/webmd_doctor/templates/guidelines.html | 2 + sites/webmd_doctor/templates/hospital.html | 2 + sites/webmd_doctor/templates/hub_index.html | 3 +- sites/webmd_doctor/templates/hub_list.html | 2 +- sites/webmd_doctor/templates/index.html | 2 +- sites/webmd_doctor/templates/practice.html | 2 + sites/webmd_doctor/templates/results.html | 3 +- sites/webmd_doctor/templates/signup.html | 2 +- .../templates/specialty_city.html | 44 ++++++- .../templates/specialty_landing.html | 15 +-- .../templates/specialty_state.html | 4 +- 22 files changed, 238 insertions(+), 78 deletions(-) diff --git a/sites/webmd_doctor/README.md b/sites/webmd_doctor/README.md index e72f0a0e..444381e8 100644 --- a/sites/webmd_doctor/README.md +++ b/sites/webmd_doctor/README.md @@ -20,7 +20,7 @@ The Docker build regenerates `instance_seed/webmd_doctor.db` from `seed_data.py` |---|---|---|---| | doctors | 226 (202 within 40 mi of Newark, DE 19711 + 24 in Baltimore, MD) | locations | 348 | | specialties | 10 | conditions / procedures / expertise_areas | 40 / 30 / 40 | -| doctor_conditions / doctor_procedures / doctor_expertise | 1677 / 1252 / 686 | insurers / insurance_plans / doctor_insurances | 12 / 28 / 2274 | +| doctor_conditions / doctor_procedures / doctor_expertise | 1677 / 1252 / 686 | insurers / insurance_plans / doctor_insurances | 12 / 28 / 2208 | | cities / city_zips | 8 / 24 | hospitals / practices | 12 / 30 | | reviews | 1206 | doctor_perspectives | 1582 | | certifications / licenses / education | 296 / 316 / 567 | awards / doctor_languages | 50 / 351 | diff --git a/sites/webmd_doctor/app.py b/sites/webmd_doctor/app.py index 47873b4b..dc37cacd 100644 --- a/sites/webmd_doctor/app.py +++ b/sites/webmd_doctor/app.py @@ -1049,9 +1049,21 @@ def search_heading_term(params: dict) -> str: insurer = db.session.get(Insurer, params["insuranceid"]) if insurer is not None: return f"Providers accepting {insurer.name}" + if params["q"].strip() and params.get("resolved") and not params["resolved"]["matched"]: + return "All Providers" # every unmatched token is ignored (mirror renders a notice instead) return params["q"].strip() or "All Providers" +def unmatched_search_text(params: dict) -> str: + """The typed search text when none of it resolved to a seeded vocabulary term.""" + text = params["q"].strip() + if not text or params.get("resolved") is None or params["resolved"]["matched"]: + return "" + if any(params[key] is not None for key in ("sids", "cid", "pid", "insuranceid")): + return "" + return text + + def filter_bar_context(params: dict, *, show_distance: bool = True) -> dict: return { "params": params, @@ -1191,6 +1203,7 @@ def results(): loc=loc, page=page, term=term, + unmatched_q=unmatched_search_text(params), heading_place=loc["label"], filter_bar=filter_bar_context(params, show_distance=True), saved_ids=saved_doctor_ids(), @@ -1518,7 +1531,7 @@ def specialty_city(spec: str, state: str, city: str): city_row = City.query.filter_by(state_slug=state, slug=city).first() if city_row is None: abort(404) - params = read_search_params({"sids": specialty.id, "city_id": city_row.id, "use_distance": False}) + params = read_search_params({"sids": specialty.id, "city_id": city_row.id, "use_distance": True}) params["loc_label"] = "" ranked = search_doctors(params, city_row) page = paginate(ranked, params["page"]) @@ -1535,9 +1548,12 @@ def specialty_city(spec: str, state: str, city: str): city=city_row, page=page, params=params, - filter_bar=filter_bar_context(params, show_distance=False), + filter_bar=filter_bar_context(params, show_distance=True), saved_ids=saved_doctor_ids(), base_path=url_for("specialty_city", spec=spec, state=state, city=city), + related_specialties=[row for row in specialties_for_menu() if row.id != specialty.id], + all_conditions=Condition.query.filter_by(specialty_id=specialty.id).order_by(Condition.id).all(), + all_procedures=Procedure.query.filter_by(specialty_id=specialty.id).order_by(Procedure.id).all(), page_qs=lambda n: filter_query_string(params, page=n), total=len(all_rows), average_experience=average_experience, diff --git a/sites/webmd_doctor/seed_data.py b/sites/webmd_doctor/seed_data.py index 5d5e5a14..473e3c1a 100644 --- a/sites/webmd_doctor/seed_data.py +++ b/sites/webmd_doctor/seed_data.py @@ -103,7 +103,7 @@ "doctor_conditions": 1677, "doctor_procedures": 1252, "doctor_expertise": 686, - "doctor_insurances": 2274, + "doctor_insurances": 2208, "reviews": 1206, "doctor_perspectives": 1582, "certifications": 296, @@ -1132,6 +1132,26 @@ def _topup_languages(doctors: list[Doctor]) -> None: db.session.flush() +def _sync_public_payer_rows(doctors: list[Doctor]) -> None: + """The Medicare / Medicaid entries of the profile's Insurance card follow the primary office's + Accepts-Medicare / Accepts-Medicaid flags (the values the results filter and the practice page + use). Deterministic, consumes no RNG, so it runs after every other builder.""" + payer_plans = {} + for insurer in Insurer.query.filter(Insurer.slug.in_(("medicare", "medicaid"))).order_by(Insurer.id).all(): + payer_plans[insurer.slug] = InsurancePlan.query.filter_by(insurer_id=insurer.id).order_by(InsurancePlan.id).all() + for doctor in doctors: + primary = next(location for location in doctor.locations if location.is_primary) + for slug, accepted in (("medicare", primary.medicare), ("medicaid", primary.medicaid)): + plan_ids = {plan.id for plan in payer_plans[slug]} + rows = [row for row in doctor.insurances if row.plan_id in plan_ids] + if accepted and not rows: + db.session.add(DoctorInsurance(doctor_id=doctor.id, plan_id=payer_plans[slug][0].id, is_verified=True)) + elif not accepted: + for row in rows: + db.session.delete(row) + db.session.flush() + + def seed_database(force: bool = False) -> None: if Doctor.query.count() > 0 and not force: return @@ -1146,6 +1166,7 @@ def seed_database(force: bool = False) -> None: _build_awards(doctors, vocab) _finish_hubs(Hospital.query.order_by(Hospital.id).all(), Practice.query.order_by(Practice.id).all()) _topup_languages(doctors) + _sync_public_payer_rows(doctors) for doctor in doctors: del doctor._slot del doctor._practice diff --git a/sites/webmd_doctor/static/css/site.css b/sites/webmd_doctor/static/css/site.css index 7634fc30..dced8db7 100644 --- a/sites/webmd_doctor/static/css/site.css +++ b/sites/webmd_doctor/static/css/site.css @@ -113,15 +113,19 @@ button, input, select, textarea { font: inherit; } .top-docs .kicker { color: var(--card-navy); font-weight: 700; font-size: 22px; } .top-docs .place { color: var(--blue); font-size: 22px; margin-bottom: 26px; } .mini-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; } -.mini-grid.six { grid-template-columns: repeat(5, 1fr); } +.mini-grid.six { display: flex; overflow-x: auto; padding-bottom: 6px; } +.mini-grid.six .mini-card { flex: 0 0 300px; } .mini-grid.three { grid-template-columns: repeat(3, 1fr); max-width: 900px; margin: 0 auto; } -.mini-card { background: #fff; border: 1px solid var(--border); border-radius: 6px; padding: 22px 14px; text-align: center; } +.mini-card { background: #fff; border: 1px solid var(--border); border-radius: 6px; padding: 22px 14px; text-align: center; display: flex; flex-direction: column; } .mini-card .avatar { width: 70px; height: 70px; border-radius: 50%; margin: 0 auto 10px; display: block; } .mini-card .name { font-weight: 700; font-size: 16px; color: var(--text); } .mini-card .spec { font-size: 13px; color: var(--text-3); } .mini-card .rating-line { font-size: 12px; margin: 6px 0; justify-content: center; flex-wrap: wrap; gap: 4px; } .mini-card .meta { font-size: 13px; color: var(--text-3); display: flex; flex-direction: column; gap: 2px; align-items: center; } -.mini-card .btn { margin-top: 14px; } +.mini-card .btn { margin-top: auto; padding-top: 8px; } +.mini-card .meta + .btn { margin-top: auto; } +.mini-card .btn { margin-top: auto; } +.mini-card .meta { margin-bottom: 14px; } .btn.upper { text-transform: uppercase; letter-spacing: .5px; } .awards-band { background: var(--navy); color: #fff; padding: 60px 0; } .awards-band .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; align-items: center; } @@ -139,6 +143,7 @@ button, input, select, textarea { font: inherit; } .browse-popular { padding: 40px 0; background: #fff; } .browse-popular h2 { font-size: 15px; letter-spacing: .5px; color: var(--card-navy); text-transform: uppercase; border-bottom: 2px solid var(--blue); display: inline-block; margin-bottom: 20px; } .browse-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px 18px; } +.browse-grid a .caret { color: var(--blue); font-size: 18px; line-height: 1; } .browse-grid a { display: flex; justify-content: space-between; align-items: center; border: 1px solid var(--border-2); padding: 10px 12px; color: var(--text); font-size: 14px; font-weight: 600; border-radius: 3px; } /* ---------- results ---------- */ @@ -152,9 +157,11 @@ button, input, select, textarea { font: inherit; } .notice { background: #fff7e0; border: 1px solid #f2d78a; border-radius: 4px; padding: 10px 14px; margin: 0 0 16px; font-size: 14px; } .filter-bar { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 18px; } .filter-item { position: relative; } -.pill { display: inline-flex; align-items: center; gap: 10px; height: 42px; padding: 0 16px; border: 1px solid var(--border-2); border-radius: 3px; background: #fff; color: var(--text); font-weight: 600; cursor: pointer; font-size: 16px; } -.pill .ic { color: var(--text-2); } -.pill.active { border-color: var(--navy); box-shadow: inset 0 0 0 1px var(--navy); } +.pill { display: inline-flex; align-items: center; gap: 10px; height: 42px; padding: 0 16px; border: 1px solid var(--border-2); border-radius: 3px; background: #fff; color: var(--blue); font-weight: 600; cursor: pointer; font-size: 16px; } +.pill .ic { color: var(--blue); } +.pill-primary { border-color: var(--blue); box-shadow: inset 0 0 0 1px var(--blue); } +.pill.active { border-color: var(--navy); box-shadow: inset 0 0 0 1px var(--navy); color: var(--text); } +.pill.active .ic { color: var(--text-2); } .pill-icon .ic { color: var(--blue); } .pill input[type=checkbox] { width: 18px; height: 18px; accent-color: var(--blue); margin: 0; } .pill-icon { width: 42px; justify-content: center; padding: 0; } @@ -166,6 +173,7 @@ button, input, select, textarea { font: inherit; } .popover .row { display: flex; gap: 10px; align-items: center; } .popover .apply { margin-top: 10px; display: flex; justify-content: flex-end; gap: 8px; } .info-banner { display: flex; align-items: center; gap: 14px; background: #fff; border: 1px solid var(--border); border-radius: 4px; padding: 12px 16px; margin-bottom: 18px; font-size: 15px; } +.info-banner .close { margin-left: auto; background: none; border: 0; font-size: 22px; line-height: 1; color: var(--text-3); cursor: pointer; padding: 0 4px; } .info-banner .badge { width: 44px; height: 44px; border-radius: 50%; background: var(--bg-2); color: var(--card-navy); display: flex; align-items: center; justify-content: center; font-size: 9px; font-weight: 700; text-align: center; line-height: 1.1; flex: none; border: 1px solid #c6cff5; } .section-label { color: var(--text-3); font-size: 15px; margin: 8px 0 12px; } .card-list { display: flex; flex-direction: column; gap: 16px; } @@ -182,7 +190,7 @@ button, input, select, textarea { font: inherit; } .rating-line { display: flex; align-items: center; gap: 6px; font-size: 15px; margin: 4px 0 8px; } .rating-line .num { font-weight: 600; } .stars { display: inline-flex; gap: 1px; } -.stars .ic { width: 17px; height: 17px; color: var(--gold); } +.stars .ic { width: 21px; height: 21px; color: var(--gold); } .stars .ic.off { color: #d8d8d8; } .stars.small .ic { width: 13px; height: 13px; } .facts { display: flex; flex-direction: column; gap: 3px; font-size: 14px; color: var(--text-2); } @@ -192,6 +200,7 @@ button, input, select, textarea { font: inherit; } .addr { margin: 10px 0 8px; font-size: 14px; color: var(--text-2); } .addr .dist { margin-left: 10px; } .snippet { font-size: 15px; color: var(--text-2); line-height: 1.35; } +.snippet .view-link { white-space: nowrap; } .tele-pill { display: inline-block; background: var(--bg-2); color: var(--card-navy); font-size: 13px; padding: 4px 12px; border-radius: 3px; margin: 8px 0; } .phys-card .cta-col { width: 300px; flex: none; display: flex; flex-direction: column; gap: 10px; align-items: stretch; } .phys-card .cta-col .btn { border-radius: 22px; font-size: 16px; } @@ -203,7 +212,7 @@ button, input, select, textarea { font: inherit; } .pagination a, .pagination span { min-width: 34px; height: 34px; display: inline-flex; align-items: center; justify-content: center; border-radius: 3px; font-weight: 600; padding: 0 8px; } .pagination a { background: #fff; border: 1px solid var(--border-2); color: var(--text-2); } .pagination a:hover { text-decoration: none; border-color: var(--blue); color: var(--blue); } -.pagination .current { background: var(--blue); color: #fff; } +.pagination .current { background: #e4e7ed; color: var(--text); font-weight: 700; } .pagination .ellipsis { color: var(--muted); } .pagination a.nav { background: var(--blue); color: #fff; border-color: var(--blue); } .pagination span.nav { color: #c8c8c8; } @@ -212,29 +221,30 @@ button, input, select, textarea { font: inherit; } .profile-top { position: relative; margin-bottom: 20px; } .profile-hero { background: var(--card-navy); color: #fff; border-radius: 4px 4px 0 0; position: relative; overflow: hidden; } .has-widget .profile-hero { padding-right: 424px; min-height: 470px; } -.profile-hero .hero-main { padding: 22px 28px 28px; display: grid; grid-template-columns: 170px 1fr; gap: 20px; position: relative; z-index: 1; } +.profile-hero .hero-main { padding: 30px 32px 34px; display: grid; grid-template-columns: 180px 1fr; gap: 24px; position: relative; z-index: 1; } .profile-hero .hero-art { position: absolute; right: 0; bottom: 0; width: 520px; height: 300px; opacity: .55; pointer-events: none; } .profile-hero .avatar-col { text-align: center; } -.profile-hero .avatar { width: 170px; height: 170px; border-radius: 50%; display: block; border: 3px solid rgba(255,255,255,.2); } +.profile-hero .avatar { width: 180px; height: 180px; border-radius: 50%; display: block; border: 3px solid rgba(255,255,255,.2); } .profile-hero .play { position: absolute; } .profile-hero .verified { display: inline-flex; align-items: center; gap: 4px; margin-top: 8px; font-weight: 700; font-size: 14px; letter-spacing: .5px; } -.profile-hero h1 { font-size: 40px; margin-bottom: 4px; } -.profile-hero .spec { font-size: 22px; font-weight: 700; margin: 12px 0 8px; } -.profile-hero .rating-line { font-size: 16px; } +.profile-hero h1 { font-size: 46px; margin-bottom: 6px; } +.profile-hero .spec { font-size: 24px; font-weight: 700; margin: 12px 0 8px; } +.profile-hero .rating-line { font-size: 18px; } .profile-hero a { color: #fff; text-decoration: underline; } -.profile-hero .facts { font-size: 15px; color: #fff; gap: 8px; margin-top: 10px; } +.profile-hero .facts { font-size: 17px; color: #fff; gap: 10px; margin-top: 12px; } .profile-hero .facts .ic { color: #fff; } .profile-hero .facts strong { font-weight: 700; } -.accept-pill { display: inline-block; background: #fff; color: var(--card-navy); font-weight: 600; font-size: 14px; padding: 4px 10px; border-radius: 3px; margin: 8px 0; } +.accept-pill { display: inline-block; background: #fff; color: var(--card-navy); font-weight: 600; font-size: 15px; padding: 5px 12px; border-radius: 3px; margin: 8px 0; } +.profile-hero .accept-pill { display: table; } .hero-phone { display: inline-flex; margin: 8px 0; } .affiliation { position: absolute; top: 18px; right: 20px; background: #fff; color: var(--text-2); border-radius: 4px; padding: 8px 14px; display: flex; align-items: center; gap: 12px; font-size: 11px; line-height: 1.2; z-index: 2; } .affiliation strong { font-size: 13px; color: var(--card-navy); } .affiliation a { color: var(--card-navy); text-decoration: none; } .hero-side { position: absolute; top: 92px; right: 20px; width: 384px; background: #fff; color: var(--text); padding: 22px 24px 24px; border: 1px solid var(--border); border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,.12); z-index: 3; } -.hero-side h3 { font-size: 18px; margin-bottom: 14px; } +.hero-side h3 { font-size: 20px; margin-bottom: 14px; } .grid-more { text-align: center; color: var(--blue); margin-top: 6px; } .grid-more .ic { transform: rotate(0deg); } -.book-widget label.lbl { display: block; font-weight: 700; font-size: 14px; margin: 10px 0 6px; } +.book-widget label.lbl { display: block; font-weight: 700; font-size: 15px; margin: 10px 0 6px; } .book-widget select { width: 100%; padding: 10px; border: 1px solid var(--border-2); border-radius: 3px; } .seg { display: flex; gap: 10px; } .seg label { flex: 1; display: flex; align-items: center; gap: 8px; border: 1px solid var(--border-2); border-radius: 3px; padding: 8px 10px; font-size: 14px; cursor: pointer; } @@ -244,19 +254,26 @@ button, input, select, textarea { font: inherit; } .grid-days { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; } .grid-days .day-head { text-align: center; font-size: 12px; font-weight: 700; line-height: 1.2; } .grid-days .day-head span { display: block; font-weight: 400; } -.slot { display: block; text-align: center; font-size: 12px; padding: 6px 2px; border: 1px solid var(--border-2); border-radius: 3px; cursor: pointer; margin-top: 6px; } +.slot { display: block; text-align: center; font-size: 13px; padding: 7px 2px; border: 1px solid var(--border-2); border-radius: 3px; cursor: pointer; margin-top: 6px; } .slot input { position: absolute; opacity: 0; } .slot.on, .slot:has(input:checked) { background: var(--bg-2); border-color: var(--blue); color: var(--blue); font-weight: 700; } -.tabs { display: flex; gap: 20px; background: #fff; border: 1px solid var(--border); border-top: 0; padding: 0 20px; border-radius: 0 0 4px 4px; } +.tabs { display: flex; gap: 26px; background: #fff; border: 1px solid var(--border); border-top: 0; padding: 0 24px; border-radius: 0 0 4px 4px; position: sticky; top: 0; z-index: 20; } .has-widget .tabs { margin-right: 424px; } -.tabs a { padding: 14px 0; font-weight: 700; color: var(--text-2); border-bottom: 3px solid transparent; font-size: 14px; letter-spacing: .3px; } +.tabs a { padding: 18px 0; font-weight: 700; color: var(--text-2); border-bottom: 3px solid transparent; font-size: 17px; letter-spacing: .3px; } +.sticky-doc { position: sticky; top: 0; z-index: 25; background: #fff; border-bottom: 1px solid var(--border); box-shadow: 0 2px 8px rgba(0,0,0,.08); } +.sticky-doc .inner { display: flex; align-items: center; gap: 16px; padding: 8px 0; } +.sticky-doc .avatar { width: 44px; height: 44px; border-radius: 50%; } +.sticky-doc .who { display: flex; flex-direction: column; font-size: 15px; margin-right: auto; } +.sticky-doc .who .rating-line { margin: 0; font-size: 13px; } +.sticky-doc .accept-pill { margin: 0; background: var(--bg-2); } .tabs a.on { color: var(--card-navy); border-color: var(--blue); } .tabs a:hover { text-decoration: none; color: var(--blue); } .profile-grid { display: grid; grid-template-columns: 1fr 400px; gap: 24px; align-items: start; } -.wide-panel { grid-column: 1 / -1; background: #fff; border: 1px solid var(--border); border-radius: 4px; padding: 22px 28px; font-size: 15px; color: var(--text-2); } -.wide-panel h3 { font-size: 16px; margin-bottom: 12px; color: var(--text); } +.wide-panel { grid-column: 1 / -1; background: #fff; border: 1px solid var(--border); border-radius: 4px; padding: 26px 32px; font-size: 16px; color: var(--text-2); } +.wide-panel.grey { background: var(--bg); border-color: transparent; } +.wide-panel h3 { font-size: 18px; margin-bottom: 14px; color: var(--text); } .wide-panel + .wide-panel { margin-top: -8px; } -.rail-panel h3 { font-size: 16px; margin-bottom: 12px; } +.rail-panel h3 { font-size: 18px; margin-bottom: 12px; } .colleague-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px 14px; } .colleague { text-align: center; font-size: 13px; color: var(--text-3); } .colleague .avatar { width: 64px; height: 64px; border-radius: 50%; margin: 0 auto 8px; display: block; } @@ -265,12 +282,12 @@ button, input, select, textarea { font: inherit; } .colleague .stars { justify-content: center; margin-top: 4px; } .stack { display: flex; flex-direction: column; gap: 16px; min-width: 0; } .rail { display: flex; flex-direction: column; gap: 16px; position: sticky; top: 16px; } -.panel { background: #fff; border: 1px solid var(--border); border-radius: 4px; padding: 22px 28px 26px; } +.panel { background: #fff; border: 1px solid var(--border); border-radius: 4px; padding: 28px 32px 30px; } .panel-head { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 14px; } .panel-head .ic { width: 22px; height: 22px; color: var(--blue); margin-top: 2px; } -.panel-head h2 { font-size: 17px; font-weight: 700; } +.panel-head h2 { font-size: 20px; font-weight: 700; } .panel-head h2::after { content: ""; display: block; width: 40px; height: 3px; background: #c9d1ff; margin-top: 8px; } -.panel-body { padding-left: 38px; font-size: 15px; color: var(--text-2); } +.panel-body { padding-left: 38px; font-size: 17px; color: var(--text-2); } .panel-body p { line-height: 1.5; } .panel-body li { line-height: 1.5; } .bio ul { list-style: disc; padding-left: 22px; margin: 6px 0 12px; } @@ -280,7 +297,7 @@ button, input, select, textarea { font: inherit; } .bio-clip.expanded { max-height: none; } .video-poster { position: relative; display: block; } .video-poster img { width: 100%; border-radius: 4px; display: block; } -.quote { font-size: 15px; line-height: 1.6; } +.quote { font-size: 17px; line-height: 1.6; } .loc { display: grid; grid-template-columns: 1fr 200px; gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--border); } .loc:last-child { border-bottom: 0; } .loc .practice-link { font-weight: 700; text-decoration: underline; color: var(--text); display: inline-block; margin-bottom: 4px; } @@ -290,7 +307,7 @@ button, input, select, textarea { font: inherit; } .loc.stacked .map { height: 170px; } .flag-lines { margin: 12px 0 0 22px; font-size: 14px; line-height: 1.6; } .loc.stacked .hours { margin-left: 22px; } -.hours { display: grid; grid-template-columns: 44px 1fr; gap: 2px 8px; font-size: 14px; margin-top: 10px; } +.hours { display: grid; grid-template-columns: 48px 1fr; gap: 2px 8px; font-size: 15px; margin-top: 10px; } .hours dt, .hours dd { margin: 0; } .review-summary { display: grid; grid-template-columns: 1fr auto; gap: 16px; align-items: start; } .review-summary .big { font-size: 22px; font-weight: 700; display: flex; align-items: center; gap: 8px; } @@ -308,7 +325,7 @@ button, input, select, textarea { font: inherit; } .review-controls .fake-search { min-width: 150px; justify-content: space-between; color: var(--muted); } .review { padding: 14px 0; border-bottom: 1px solid var(--border); } .review:last-child { border-bottom: 0; } -.review .text { margin: 6px 0; font-size: 15px; line-height: 1.5; } +.review .text { margin: 6px 0; font-size: 16px; line-height: 1.5; } .review .date { font-size: 13px; color: var(--text-3); } .review .actions { display: flex; gap: 10px; align-items: center; margin-top: 6px; } .review details summary { color: var(--blue); cursor: pointer; font-size: 14px; } @@ -339,9 +356,18 @@ details.review-form > summary::-webkit-details-marker { display: none; } .cond .tier-labels { display: grid; grid-template-columns: repeat(3, 1fr); font-size: 13px; text-align: center; color: var(--text-3); } .cond .tier-labels .on { color: var(--card-navy); font-weight: 700; } .top20 summary { color: var(--blue); cursor: pointer; text-decoration: underline; margin-top: 8px; } +.top20 summary::after { content: " ⌄"; } +.top20[open] summary::after { content: " ⌃"; } .top20 ol { padding-left: 22px; margin: 12px 0 0; columns: 2; list-style: disc; } .top20 ol li { padding: 3px 0; break-inside: avoid; } .inline-list { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 20px; } +.below-list { margin-top: 24px; font-size: 16px; color: var(--text-2); } +.below-list .sub-title { font-size: 20px; color: var(--text); margin-bottom: 12px; padding-bottom: 8px; border-bottom: 3px solid #c9d1ff; display: inline-block; } +.below-list .bullets { list-style: disc; padding-left: 22px; } +.below-list .bullets li { padding: 3px 0; } +.below-list.faq dd { margin: 6px 0 0; line-height: 1.5; } +.spec-grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px 20px; } +.spec-grid-3 a { color: var(--text-2); } .inline-list li { padding: 2px 0; } .award-box { display: flex; gap: 16px; align-items: flex-start; } .award-box .seal { width: 64px; height: 64px; border-radius: 50%; border: 3px solid #c9d1ff; background: var(--bg-2); color: var(--card-navy); font-size: 9px; font-weight: 700; text-align: center; display: flex; align-items: center; justify-content: center; flex: none; line-height: 1.1; padding: 4px; } @@ -353,15 +379,18 @@ details.review-form > summary::-webkit-details-marker { display: none; } .poll .yn span { border: 1px solid var(--blue); color: var(--blue); border-radius: 12px; padding: 1px 12px; font-size: 13px; } .poll .counts { display: flex; gap: 8px; align-items: center; font-size: 13px; } .poll .counts .bar { width: 90px; height: 8px; background: #d0d0d0; border-radius: 4px; } -.kv h4 { font-size: 14px; margin: 12px 0 4px; } +.kv h4 { font-size: 15px; margin: 14px 0 6px; padding-top: 12px; border-top: 1px solid var(--border); } .kv h5 { font-size: 12px; letter-spacing: 1px; color: var(--text-3); margin: 10px 0 4px; } -.faq dt { font-weight: 700; margin-top: 12px; } +.faq dt { font-weight: 700; margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border); } +.faq dt:first-child { border-top: 0; padding-top: 0; } .faq dd { margin: 4px 0 0; } -.city-links { display: flex; flex-wrap: wrap; gap: 8px 20px; } +.tag.active { background: var(--bg-2); border-color: var(--card-navy); } +.city-links { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px 20px; } +.city-links a { color: var(--text-2); } .rail-list { display: flex; flex-direction: column; gap: 14px; } .rail-row { display: flex; gap: 12px; align-items: flex-start; } .rail-row .avatar { width: 56px; height: 56px; border-radius: 50%; flex: none; } -.rail-row .name { font-weight: 700; font-size: 15px; } +.rail-row .name { font-weight: 700; font-size: 16px; } .rail-row .name a { color: var(--text-2); } .rail-row .sub { font-size: 13px; color: var(--text-3); } .rail-row .stars .ic { width: 13px; height: 13px; } @@ -388,7 +417,7 @@ details.review-form > summary::-webkit-details-marker { display: none; } .book-form { max-width: 620px; margin: 0 auto; } .book-form h3 { font-size: 16px; margin: 20px 0 10px; } .book-form .seg label { padding: 14px 16px; font-size: 16px; } -.book-form select { width: 100%; padding: 14px 16px; border: 1px solid var(--border-2); border-radius: 3px; font-size: 16px; } +.book-form select { width: 100%; padding: 14px 44px 14px 16px; border: 1px solid var(--border-2); border-radius: 3px; font-size: 16px; font-weight: 600; background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 6l4 4 4-4' fill='none' stroke='%233557ff' stroke-width='2'/%3E%3C/svg%3E") no-repeat right 14px center / 16px; appearance: none; -webkit-appearance: none; } .book-form .grid-days { max-width: 620px; } .book-form .submit { display: flex; justify-content: flex-end; margin-top: 30px; } .errors { background: #fdeaea; border: 1px solid #f3b8b8; color: #8f1d1d; border-radius: 4px; padding: 10px 14px; margin-bottom: 16px; } @@ -400,7 +429,9 @@ details.review-form > summary::-webkit-details-marker { display: none; } .confirm-box dd { margin: 0; } /* ---------- auth ---------- */ -.auth-backdrop { background: rgba(0,0,0,.72); padding: 40px 0 80px; min-height: 70vh; } +.auth-backdrop { padding: 40px 0 80px; min-height: 70vh; position: relative; } +.auth-backdrop::before { content: ""; position: fixed; inset: 0; background: rgba(0,0,0,.72); z-index: 60; } +.auth-backdrop .auth-modal { z-index: 61; } .auth-modal { background: #fff; width: 1024px; max-width: 100%; margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; min-height: 636px; position: relative; } .auth-art { background: linear-gradient(200deg, #1b3bd6, var(--navy) 80%); color: #fff; padding: 40px; display: flex; flex-direction: column; justify-content: flex-end; } .auth-art h2 { font-size: 34px; font-weight: 400; margin-bottom: 10px; } @@ -445,7 +476,8 @@ details.review-form > summary::-webkit-details-marker { display: none; } .hub-mini h3 a { color: var(--text); } .hub-mini .sub { font-size: 13px; color: var(--text-2); } .hub-mini .btn { margin-top: auto; } -.find-doctors-strip { background: #fff; border: 1px solid var(--border); border-radius: 4px; padding: 16px 20px; margin-top: 30px; font-size: 14px; } +.find-doctors-strip { padding: 16px 0 0; margin-top: 30px; font-size: 14px; border-top: 1px solid var(--border); } +.find-doctors-strip > a { font-weight: 700; color: var(--text); } .find-doctors-strip .links { display: flex; flex-wrap: wrap; margin-top: 10px; } .find-doctors-strip .links a { color: var(--text-2); padding: 3px 12px; border-right: 1px solid var(--border-2); } .find-doctors-strip .links a:last-child { border-right: 0; } @@ -456,14 +488,15 @@ details.review-form > summary::-webkit-details-marker { display: none; } .hub-card h3 a { color: var(--text); } .hub-card .sub { font-size: 14px; color: var(--text-2); } .hub-card .desc { font-size: 14px; color: var(--text-2); margin-top: 8px; } -.hub-hero { background: linear-gradient(90deg, var(--bg-2) 60%, #c9d1ff); border-radius: 4px 4px 0 0; padding: 24px 36px; position: relative; overflow: hidden; } +.hub-hero { background: linear-gradient(90deg, var(--bg-2) 60%, #c9d1ff); border-radius: 4px 4px 0 0; padding: 30px 36px; position: relative; overflow: hidden; } .hub-hero::after { content: ""; position: absolute; right: -40px; top: -60px; width: 200px; height: 200px; border-radius: 50%; background: var(--card-navy); opacity: .7; } .hub-hero::before { content: ""; position: absolute; right: 120px; top: 10px; width: 160px; height: 160px; border-radius: 50%; border: 2px solid #fff; opacity: .6; } .hub-hero h1 { position: relative; z-index: 1; } -.hub-hero h1 { color: var(--card-navy); font-size: 38px; } +.hub-hero h1 { color: var(--card-navy); font-size: 44px; } .hub-hero.navy { background: var(--card-navy); } .hub-hero.navy h1 { color: #fff; } -.hub-meta { background: #fff; padding: 18px 36px 24px; border: 1px solid var(--border); border-top: 0; margin-bottom: 20px; } +.hub-meta { background: #fff; padding: 18px 36px 24px; border: 1px solid var(--border); border-top: 0; margin-bottom: 20px; width: calc(100% - 424px); } +.rule { border: 0; border-top: 1px solid var(--border-2); margin: -16px 0 28px; } .hub-meta .stats { color: var(--blue); font-weight: 700; font-size: 15px; } .hub-meta .stats span { margin-right: 14px; } .phys-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px 24px; } @@ -544,6 +577,15 @@ details.review-form > summary::-webkit-details-marker { display: none; } .site-footer .policy a { color: #fff; font-weight: 600; } .site-footer .legal { display: flex; justify-content: space-between; align-items: center; gap: 20px; margin-top: 30px; font-size: 12px; } .site-footer .legal img { height: 26px; } +.site-footer .badges { display: flex; align-items: center; gap: 12px; flex: none; } +.badge-disc { display: inline-flex; flex-direction: column; align-items: center; justify-content: center; width: 58px; height: 58px; border-radius: 50%; background: #fff; color: var(--navy); font-size: 11px; font-weight: 700; line-height: 1; text-align: center; } +.badge-disc small { font-size: 7px; font-weight: 600; margin-top: 2px; } +.badge-disc.hon { color: #1f3ed6; } +.badge-disc.adc { border-radius: 8px; width: 64px; height: 30px; flex-direction: row; gap: 2px; } +.badge-disc.adc small { font-size: 9px; margin: 0; } +.privacy-toggle { display: inline-block; width: 28px; height: 14px; border-radius: 8px; background: #fff; vertical-align: -2px; margin-left: 6px; position: relative; } +.privacy-toggle i { position: absolute; left: 2px; top: 2px; width: 10px; height: 10px; border-radius: 50%; background: var(--blue); } +.privacy-toggle::after { content: "✓"; position: absolute; right: 5px; top: -1px; font-size: 10px; color: var(--blue); } @media (max-width: 1100px) { .profile-hero, .profile-grid { grid-template-columns: 1fr; } @@ -553,6 +595,8 @@ details.review-form > summary::-webkit-details-marker { display: none; } .has-widget .profile-hero { padding-right: 28px; } .hero-side { position: static; width: auto; margin: 0 0 16px; } .has-widget .tabs { margin-right: 0; } + .hub-meta { width: auto; } + .spec-grid-3, .city-links { grid-template-columns: 1fr; } .hub-mini-grid, .colleague-grid { grid-template-columns: repeat(2, 1fr); } .awards-section .grid { grid-template-columns: 1fr; } .mini-grid.six { grid-template-columns: repeat(3, 1fr); } diff --git a/sites/webmd_doctor/static/js/site.js b/sites/webmd_doctor/static/js/site.js index 02efc14c..bec824de 100644 --- a/sites/webmd_doctor/static/js/site.js +++ b/sites/webmd_doctor/static/js/site.js @@ -130,6 +130,35 @@ }); } + // Patients' Choice banner dismiss. + document.querySelectorAll("[data-dismiss]").forEach(function (button) { + button.addEventListener("click", function () { + var banner = button.closest(".info-banner"); + if (banner) { banner.hidden = true; } + }); + }); + + // "View Top 20 ..." <-> "View Less". + document.querySelectorAll("details.top20").forEach(function (details) { + var summary = details.querySelector("summary"); + if (!summary) { return; } + var closedText = summary.textContent; + details.addEventListener("toggle", function () { + summary.textContent = details.open ? (summary.getAttribute("data-open-text") || closedText) : closedText; + }); + }); + + // Compact provider bar once the hero has scrolled out of view. + var stickyDoc = document.getElementById("sticky-doc"); + var profileHero = document.querySelector(".profile-hero"); + if (stickyDoc && profileHero) { + var syncSticky = function () { + stickyDoc.hidden = profileHero.getBoundingClientRect().bottom > 0; + }; + window.addEventListener("scroll", syncSticky, { passive: true }); + syncSticky(); + } + // Overview "View more". document.querySelectorAll("[data-expand]").forEach(function (button) { button.addEventListener("click", function (event) { diff --git a/sites/webmd_doctor/templates/_footer.html b/sites/webmd_doctor/templates/_footer.html index 76f57d1d..29ff13f1 100644 --- a/sites/webmd_doctor/templates/_footer.html +++ b/sites/webmd_doctor/templates/_footer.html @@ -43,12 +43,17 @@

Download WebMD App

diff --git a/sites/webmd_doctor/templates/_physician_card.html b/sites/webmd_doctor/templates/_physician_card.html index 04716a86..4c9032b1 100644 --- a/sites/webmd_doctor/templates/_physician_card.html +++ b/sites/webmd_doctor/templates/_physician_card.html @@ -21,7 +21,7 @@

{{ doctor.displa {% if doctor.is_enhanced and doctor.virtual_visit %}Telehealth Available{% endif %}
{{ loc.address_line }}{% if distance is defined and distance is not none %}{{ distance | miles }}{% endif %}
-

"{{ doctor.card_snippet }}"

+

"{{ doctor.card_snippet }}"{% if not doctor.is_enhanced %} View Profile{% endif %}

{% if show_remove %}
diff --git a/sites/webmd_doctor/templates/award_recipients.html b/sites/webmd_doctor/templates/award_recipients.html index 9dcf6984..f018aa68 100644 --- a/sites/webmd_doctor/templates/award_recipients.html +++ b/sites/webmd_doctor/templates/award_recipients.html @@ -11,7 +11,7 @@

{{ class_title }} Award 2025– {% for option in state_options %}{% endfor %} - {% for key, value in award_classes.items() %}{{ value[2] }}{% if key == award_class %} ✕{% endif %}{% endfor %} + {{ award_classes[award_class][2] }} ✕
{% set ns = namespace(state=None) %} diff --git a/sites/webmd_doctor/templates/awards.html b/sites/webmd_doctor/templates/awards.html index 58eeb3e4..5961170b 100644 --- a/sites/webmd_doctor/templates/awards.html +++ b/sites/webmd_doctor/templates/awards.html @@ -13,8 +13,8 @@

Best Hospitals According to Patients & Health Care Providers

diff --git a/sites/webmd_doctor/templates/base.html b/sites/webmd_doctor/templates/base.html index 8b52e484..30a06ee0 100644 --- a/sites/webmd_doctor/templates/base.html +++ b/sites/webmd_doctor/templates/base.html @@ -21,7 +21,7 @@ {% endwith %} {% block content %}{% endblock %} -{% include "_footer.html" %} +{% block footer %}{% include "_footer.html" %}{% endblock %} {% block scripts %}{% endblock %} diff --git a/sites/webmd_doctor/templates/doctor.html b/sites/webmd_doctor/templates/doctor.html index 65703cce..ddd510c0 100644 --- a/sites/webmd_doctor/templates/doctor.html +++ b/sites/webmd_doctor/templates/doctor.html @@ -77,10 +77,19 @@

Book an Appointment

{% endif %} -
+
@@ -226,7 +235,7 @@

Patients' Perspective

{% endfor %} {% endif %} -
View Top 20 Conditions treated by {{ doctor.display_name }} +
View Top 20 Conditions treated by {{ doctor.display_name }}
    {% for row in (more_conditions if doctor.is_enhanced else doctor.conditions) %}
  1. {{ row.condition.name }}
  2. {% endfor %}
@@ -246,7 +255,7 @@

Patients' Perspective

{% endfor %} {% endif %} -
View Top 20 Procedures performed by {{ doctor.display_name }} +
View Top 20 Procedures performed by {{ doctor.display_name }}
    {% for row in (more_procedures if doctor.is_enhanced else doctor.procedures) %}
  1. {{ row.procedure.name }}
  2. {% endfor %}
@@ -392,14 +401,15 @@

{{ rail_title }}

{% endif %} -
+

Other {{ doctor.primary_specialty.plural }} Nearby

-
+

Data Transparency and Trust: Understanding WebMD Doctor Listings and Reviews

We know that finding the right doctor or provider is important to your health. That's why we want to ensure you have confidence in the provider profiles and listings you see on WebMD Care. Provider data in this mirror is synthetic benchmark data; on the live site it is sourced from physicians themselves and publicly available databases.

All the physician and provider reviews on WebMD Care are provided by users just like you. Providers are not able to remove or modify reviews on their own.

+

Every review is checked against the Reviews Guidelines before it is published. Newly submitted reviews appear to their author as "Pending review" until moderation is complete, so the ratings you see reflect verified patient feedback.

diff --git a/sites/webmd_doctor/templates/guidelines.html b/sites/webmd_doctor/templates/guidelines.html index 172470c4..954ffa9e 100644 --- a/sites/webmd_doctor/templates/guidelines.html +++ b/sites/webmd_doctor/templates/guidelines.html @@ -1,4 +1,6 @@ {% extends "base.html" %} +{% set hide_search_row = true %} +{% block footer %}{% endblock %} {% block title %}Reviews Guidelines{% endblock %} {% block content %}
diff --git a/sites/webmd_doctor/templates/hospital.html b/sites/webmd_doctor/templates/hospital.html index bdcdfdaf..8625c5ee 100644 --- a/sites/webmd_doctor/templates/hospital.html +++ b/sites/webmd_doctor/templates/hospital.html @@ -50,6 +50,8 @@
What is {{ hospital.name }}'s phone number?
The contact number for {{ hospital.name }} is {{ hospital.phone }}.
Has {{ hospital.name }} won any recent awards from WebMD?
{% if award_count %}{{ award_count | plural_word('physician award') }} have been earned by providers affiliated with {{ hospital.name }}.{% else %}No, {{ hospital.name }} has not received any WebMD Choice awards.{% endif %}
What specialties are available at {{ hospital.name }}?
There are {{ doctors | length }} practicing providers across {{ specialty_rows | length }} specialties working at {{ hospital.name }}. Top specialties include {{ top_specialties | join(', ') }}.
+
Does {{ hospital.name }} accept my insurance plan?
Insurance is accepted per provider. Open a physician's profile from the list above and check "Does the provider accept your insurance?", or call {{ hospital.phone }}.
+
How can I share my experience with {{ hospital.name }}?
Reviews are written on provider profiles: open one of the physicians listed at {{ hospital.name }} and choose "Leave A Review".
How do I schedule an initial appointment at {{ hospital.name }}?
You can call {{ hospital.phone }} to schedule an appointment at {{ hospital.name }} or you can call any of the providers listed under "Physicians At This Hospital".
diff --git a/sites/webmd_doctor/templates/hub_index.html b/sites/webmd_doctor/templates/hub_index.html index 1a2d47f5..6c9737b7 100644 --- a/sites/webmd_doctor/templates/hub_index.html +++ b/sites/webmd_doctor/templates/hub_index.html @@ -12,6 +12,7 @@

Find The Best {{ title }} Near You

+

Top {{ title }} {{ 'in' if kind == 'hospitals' else 'near' }} {{ anchor.state_name }}

{% for item in top_items %} @@ -32,7 +33,7 @@

Find Award-Winning Hospitals by Type of Care

{% else %}
- Find Doctors › + Find Doctors
{% endif %} diff --git a/sites/webmd_doctor/templates/hub_list.html b/sites/webmd_doctor/templates/hub_list.html index 4ace6a9f..2da45884 100644 --- a/sites/webmd_doctor/templates/hub_list.html +++ b/sites/webmd_doctor/templates/hub_list.html @@ -10,7 +10,7 @@

{{ title }} in {{ state_name }}

{% if name_filter %}{% endif %}
- +
{% for key, label in hub_sort_options %}{% endfor %}
diff --git a/sites/webmd_doctor/templates/index.html b/sites/webmd_doctor/templates/index.html index da7c4902..485c73a7 100644 --- a/sites/webmd_doctor/templates/index.html +++ b/sites/webmd_doctor/templates/index.html @@ -83,7 +83,7 @@

Find Doctors and Dentists Near You

Browse Popular Specialties

- {% for specialty in specialties %}{{ specialty.name }} {% endfor %} + {% for specialty in specialties %}{{ specialty.name }} {% endfor %}
diff --git a/sites/webmd_doctor/templates/practice.html b/sites/webmd_doctor/templates/practice.html index ed99e8ca..ff167f9b 100644 --- a/sites/webmd_doctor/templates/practice.html +++ b/sites/webmd_doctor/templates/practice.html @@ -56,6 +56,8 @@
Where is {{ practice.name }} located?
{{ practice.name }} is located at {{ practice.street }}, {{ practice.city.name }}, {{ practice.city.state }}, {{ practice.zip }}.
What is {{ practice.name }}'s phone number?
The contact number for {{ practice.name }} is {{ practice.phone }}.
What specialties are available at {{ practice.name }}?
There are {{ doctors | length }} practicing providers across {{ specialty_rows | length }} specialties at {{ practice.name }}. Top specialties include {{ top_specialties | join(', ') }}.
+
How do I schedule an initial appointment at {{ practice.name }}?
Call {{ practice.phone }} to schedule an appointment at {{ practice.name }}, or use "Request Now" on the profile of a provider who offers online appointment requests.
+
How can I share my experience with {{ practice.name }}?
Reviews are written on provider profiles: open one of the physicians listed at {{ practice.name }} and choose "Leave A Review".
Does {{ practice.name }} accept my insurance plan?
Providers at {{ practice.name }} list {{ insurers | length }} insurance carriers, including {{ insurers[:3] | join(', ') }}. To verify, call {{ practice.phone }}.
diff --git a/sites/webmd_doctor/templates/results.html b/sites/webmd_doctor/templates/results.html index 6e3acf3c..ebf06fda 100644 --- a/sites/webmd_doctor/templates/results.html +++ b/sites/webmd_doctor/templates/results.html @@ -7,8 +7,9 @@

{{ term }} near {
{{ page.total | plural_word('Result') }}
{% if loc.fallback %}
Showing providers near Newark, DE 19711 — we couldn't match '{{ loc.input }}'.
{% endif %} + {% if unmatched_q %}
Showing all providers near {{ heading_place }} — we couldn't match '{{ unmatched_q }}'. Search by specialty, condition, procedure or insurance carrier.
{% endif %} {% include "_filter_bar.html" %} -
WebMD PATIENT'S CHOICEPatients' Choice awards are assigned based on patient satisfaction ratings for key specialties in select geographic locations.
+
WebMD PATIENT'S CHOICEPatients' Choice awards are assigned based on patient satisfaction ratings for key specialties in select geographic locations.
{% if page.rows %}
diff --git a/sites/webmd_doctor/templates/signup.html b/sites/webmd_doctor/templates/signup.html index c1519d90..95c694ad 100644 --- a/sites/webmd_doctor/templates/signup.html +++ b/sites/webmd_doctor/templates/signup.html @@ -15,7 +15,7 @@ - + diff --git a/sites/webmd_doctor/templates/specialty_city.html b/sites/webmd_doctor/templates/specialty_city.html index 873d22c1..584c736c 100644 --- a/sites/webmd_doctor/templates/specialty_city.html +++ b/sites/webmd_doctor/templates/specialty_city.html @@ -6,15 +6,55 @@

Best {{ specialty.plural }} in {{ city.name }}, {{ city.state }}

{{ city.name }}, {{ city.state }} has {{ total }} {{ specialty.singular }} results with an average of {{ average_experience }} years of experience and a total of {{ total_reviews }} ratings. Currently, {{ accepting }} providers have noted they are accepting new patients. Conditions treated by {{ specialty.plural }} often include {{ conditions | map(attribute='name') | join(', ') }}. Some common procedures performed by {{ specialty.plural }} include {{ procedures | map(attribute='name') | join(', ') }}.

{% include "_filter_bar.html" %} -
WebMD PATIENT'S CHOICEPatients' Choice awards are assigned based on patient satisfaction ratings for key specialties in select geographic locations.
+
WebMD PATIENT'S CHOICEPatients' Choice awards are assigned based on patient satisfaction ratings for key specialties in select geographic locations.
{% if page.rows %}
- {% for row in page.rows %}{% with doctor=row.doctor %}{% include "_physician_card.html" %}{% endwith %}{% endfor %} + {% for row in page.rows %}{% with doctor=row.doctor, distance=row.distance %}{% include "_physician_card.html" %}{% endwith %}{% endfor %}
{% include "_pagination.html" %} {% else %}
No {{ specialty.plural }} in {{ city.name }} matched these filters.
{% endif %} +
+

What is a {{ specialty.singular }} and How Can They Help You?

+

{{ specialty.description }}

+

{{ specialty.plural }} complete medical school followed by residency training in {{ specialty.name | lower }}; board certification is issued by the {{ specialty.board_name }}. Each profile's Certifications, License & Education section lists the certifying board, the year and the training institutions.

+

In their daily practice, a {{ specialty.singular }}:

+
    +
  • Diagnoses and treats conditions including {{ all_conditions[:4] | map(attribute='name') | join(', ') }}
  • +
  • Performs procedures such as {{ all_procedures[:3] | map(attribute='name') | join(', ') }}
  • +
  • Coordinates care with the hospitals and group practices listed on each profile
  • +
  • May also be known as: {{ specialty.name | lower }} specialist, {{ specialty.singular | lower }}, MD, DO
  • +
+
+
+

Frequently Asked Questions

+
+
How can I find a {{ specialty.singular }} in {{ city.name }} who takes my insurance?
+
Use the Insurance filter at the top of this page to view only {{ specialty.plural }} who accept your carrier, Medicare or Medicaid. You can also verify acceptance on each provider's profile under "Does the provider accept your insurance?".
+
How can I schedule a virtual visit (video visit) with a {{ specialty.singular }} in {{ city.name }}?
+
Apply the "Virtual Visit" filter to find {{ specialty.plural }} offering telehealth appointments in {{ city.name }}. Profiles that show a Telehealth pill list video visit availability; Enhanced profiles also offer online appointment requests.
+
How can I find top-rated {{ specialty.plural }} near me in {{ city.name }}?
+
Sort results using the "Ratings" filter to display the most highly reviewed {{ specialty.plural }} in {{ city.name }}. Visit individual profiles to read patient feedback and the Patients' Perspective criteria.
+
How can I make a same-day appointment with a {{ specialty.singular }} in {{ city.name }}?
+
Browse individual profiles to see the next available appointment for {{ specialty.plural }} in {{ city.name }}. When same-day openings aren't visible online, phone the office listed under Locations.
+
How can I find a {{ specialty.singular }} in {{ city.name }} with the shortest wait time?
+
The average wait time shown on the Ratings & Reviews card of each Enhanced profile helps identify {{ specialty.plural }} with shorter wait times in {{ city.name }}.
+
How can I book an appointment online with a {{ specialty.singular }} in {{ city.name }}?
+
Identify {{ specialty.plural }} displaying "Request Now" buttons on their cards. These direct you to the appointment request form; other providers list phone numbers to call.
+
How can I find a {{ specialty.singular }} in {{ city.name }} who accepts new patients?
+
Tick "Accepts New Patients" in the filter bar. Enhanced cards also show an Accepting New Patients line, and every profile's Locations section lists the office policy.
+
What conditions do {{ specialty.plural }} in {{ city.name }} treat?
+
{{ specialty.plural }} on WebMD Care list conditions such as {{ all_conditions | map(attribute='name') | join(', ') }}. Each profile's Conditions Treated card shows how often the provider treats them compared with peers.
+
+
+
+

Find Other Specialists In Your City

+
+ {% for row in related_specialties %}{{ row.plural }} in {{ city.name }}, {{ city.state }}{% endfor %} + More Specialties +
+
{% endblock %} diff --git a/sites/webmd_doctor/templates/specialty_landing.html b/sites/webmd_doctor/templates/specialty_landing.html index 9ad61fc8..5cbadc25 100644 --- a/sites/webmd_doctor/templates/specialty_landing.html +++ b/sites/webmd_doctor/templates/specialty_landing.html @@ -19,23 +19,10 @@

{{ specialty.plural }} on WebMD Care

{{ average_rating | rating1 }} average
rating
These ratings help you find the best doctor that suits your specific medical needs.
{{ total }} across
{{ city_chips | length }} cities
There are {{ total }} {{ specialty.plural }} across the {{ city_chips | length }} cities covered by WebMD Care.
-
-

Frequently Asked Questions

-
-
-
What does a {{ specialty.singular | lower }} treat?
-
{{ specialty.description }} Common conditions include {{ conditions | map(attribute='name') | join(', ') }}.
-
What procedures do {{ specialty.plural | lower }} perform?
-
{{ specialty.plural }} on WebMD Care most often list {{ procedures | map(attribute='name') | join(', ') }}.
-
How are {{ specialty.plural | lower }} certified?
-
Board certification for {{ specialty.name }} is issued by the {{ specialty.board_name }}. Each profile's Certifications, License & Education section lists the certifying board and year.
-
-
-

Find {{ specialty.plural }} by City

- {% for city in city_chips %}{{ city.name }}, {{ city.state }} ({{ city.count }}){% endfor %} + {% for city in city_chips %}{{ city.name }}, {{ city.state }}{% endfor %}
diff --git a/sites/webmd_doctor/templates/specialty_state.html b/sites/webmd_doctor/templates/specialty_state.html index 76ca0e5a..f298bac0 100644 --- a/sites/webmd_doctor/templates/specialty_state.html +++ b/sites/webmd_doctor/templates/specialty_state.html @@ -2,8 +2,8 @@ {% block title %}Best {{ specialty.plural }} in {{ state_name }}{% endblock %} {% block content %}
- -

Best {{ specialty.plural }} in {{ state_name }}

+ +

Best {{ specialty.plural }} in {{ state_name }}

There are {{ total }} {{ specialty.plural }} for you to review across {{ cities | length }} cities and towns

{% for city in cities %}{{ city.name }} ({{ city.count }}){% endfor %} From b26a1d4dd13ea980253fa64690de321b9ab672ce Mon Sep 17 00:00:00 2001 From: evanz37 <89922710+evanz37@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:10:28 -0400 Subject: [PATCH 10/21] fix(webmd_doctor): faithful empty state, per-office phones, perspective/hours consistency - results: a search term in which no token resolves to a vocabulary term now renders the upstream-style empty state (heading with the typed term, "0 Results", notice, filter bar kept, browse-by-specialty link); partially matched queries still ignore the unmatched tokens - seed (RNG-free post-passes, row counts unchanged): every primary office gets its own direct line (no office shares its practice's main number, all phones unique); a practice and every office located at it share one schedule (the longest weekly office schedule); Patients' Perspective votes per criterion are bounded by the ratings count and floored at the visible review marks - in-image md5 86cd0e7a8669151405f5c6b541fb91d0; avatars/posters byte-identical (no HF change); no task id shifted; T2 phone + Saturday hours and T15 practice Saturday hours change value, every other answer is unchanged Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QGf8U2ULBCevvaYfbBBuVt --- sites/webmd_doctor/app.py | 7 +-- sites/webmd_doctor/seed_data.py | 70 +++++++++++++++++++++++ sites/webmd_doctor/templates/results.html | 4 +- 3 files changed, 76 insertions(+), 5 deletions(-) diff --git a/sites/webmd_doctor/app.py b/sites/webmd_doctor/app.py index dc37cacd..2d304a08 100644 --- a/sites/webmd_doctor/app.py +++ b/sites/webmd_doctor/app.py @@ -1049,8 +1049,6 @@ def search_heading_term(params: dict) -> str: insurer = db.session.get(Insurer, params["insuranceid"]) if insurer is not None: return f"Providers accepting {insurer.name}" - if params["q"].strip() and params.get("resolved") and not params["resolved"]["matched"]: - return "All Providers" # every unmatched token is ignored (mirror renders a notice instead) return params["q"].strip() or "All Providers" @@ -1194,7 +1192,8 @@ def results(): params["sids_explicit"] = single_arg("sids", "").isdigit() loc = resolve_location(single_arg("loc", ""), single_arg("zc", ""), single_arg("city", ""), single_arg("state", "")) params["loc_label"] = loc["label"] - rows = search_doctors(params, loc["city"]) + unmatched_q = unmatched_search_text(params) + rows = [] if unmatched_q else search_doctors(params, loc["city"]) # nothing resolved -> upstream-style empty state page = paginate(rows, params["page"]) term = search_heading_term(params) return render_template( @@ -1203,7 +1202,7 @@ def results(): loc=loc, page=page, term=term, - unmatched_q=unmatched_search_text(params), + unmatched_q=unmatched_q, heading_place=loc["label"], filter_bar=filter_bar_context(params, show_distance=True), saved_ids=saved_doctor_ids(), diff --git a/sites/webmd_doctor/seed_data.py b/sites/webmd_doctor/seed_data.py index 473e3c1a..07f2a033 100644 --- a/sites/webmd_doctor/seed_data.py +++ b/sites/webmd_doctor/seed_data.py @@ -337,6 +337,7 @@ SECONDARY_OFFICE_TAGS = ["North Office", "Medical Arts Building", "Outpatient Center", "Professional Plaza", "Annex", "Satellite Office", "Pavilion", "Wellness Center"] SECONDARY_STREETS = ["Concord Pike", "Kirkwood Hwy", "Limestone Rd", "Marsh Rd", "Naamans Rd", "Elkton Rd", "Pulaski Hwy", "Lancaster Pike", "Baltimore Pike", "Paoli Pike", "Salem Quinton Rd", "Route 40", "Silverside Rd", "Chestnut Hill Rd", "Old Baltimore Pike", "Eastern Ave", "Falls Rd", "Harford Rd"] STREET_TYPES = ["Ste 100", "Ste 210", "Ste 305", "Bldg B", "Fl 2", "Ste 12", "Ste 400"] +DAY_KEYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun") HOURS_PATTERNS = [ # (mon-fri open, close, sat open, sat close, sun open, sun close) ("8:00 am", "5:00 pm", None, None, None, None), @@ -1152,6 +1153,72 @@ def _sync_public_payer_rows(doctors: list[Doctor]) -> None: db.session.flush() +def _assign_office_lines(doctors: list[Doctor]) -> None: + """Every office gets its own direct line: primary offices were seeded with the practice's main + number, which would surface a Basic doctor's office phone on colleagues' cards. Deterministic + in the location id (no RNG); numbers stay unique across hospitals, practices and offices.""" + used = {row.phone for row in Hospital.query.all()} | {row.phone for row in Practice.query.all()} + used |= {row.phone for row in Location.query.all()} + for location in Location.query.order_by(Location.id).all(): + practice = location.practice + if location.phone != practice.phone: + continue + area, last4 = practice.phone[1:4], int(practice.phone[-4:]) + step = 0 + while True: + candidate = f"({area}) 555-{(last4 + 37 * location.id + 101 * step) % 9000 + 1000:04d}" + if candidate not in used: + break + step += 1 + used.add(candidate) + location.phone = candidate + db.session.flush() + + +def _unify_practice_hours() -> None: + """A practice publishes one schedule and every office located at it posts the same hours. + The site schedule is the longest weekly schedule among the offices at the practice (ties by + location id), so a practice never contradicts its own offices. No RNG.""" + def minutes(value: str | None) -> int: + if not value: + return 0 + clock, meridiem = value.split(" ") + hours, mins = (int(part) for part in clock.split(":")) + return (hours % 12 + (12 if meridiem == "pm" else 0)) * 60 + mins + + def weekly(target) -> int: + return sum(max(0, minutes(getattr(target, f"{key}_close")) - minutes(getattr(target, f"{key}_open"))) for key in DAY_KEYS) + + def pattern(target) -> tuple: + return tuple(getattr(target, f"{key}_{edge}") for key in DAY_KEYS for edge in ("open", "close")) + + for practice in Practice.query.order_by(Practice.id).all(): + offices = sorted(practice.locations, key=lambda row: row.id) + if not offices: + continue + site = max(offices, key=lambda row: (weekly(row), -row.id)) + values = pattern(site) + for target in [practice, *offices]: + for (key, edge), value in zip(((key, edge) for key in DAY_KEYS for edge in ("open", "close")), values): + setattr(target, f"{key}_{edge}", value) + db.session.flush() + + +def _bound_perspective_votes(doctors: list[Doctor]) -> None: + """Patients' Perspective votes are bounded by the doctor's ratings: per criterion, + did-well + needs-improvement <= ratings_count, and each side is at least the number of + visible reviews that marked it that way. No RNG.""" + for doctor in doctors: + total = doctor.ratings_count + for row in doctor.perspectives: + marks = [getattr(review, f"c{row.criterion}") for review in doctor.reviews] + visible_yes = sum(1 for mark in marks if mark == 1) + visible_no = len(marks) - visible_yes + row.did_well = max(visible_yes, min(row.did_well, total - visible_no)) + row.needs_improvement = max(visible_no, min(row.needs_improvement, total - row.did_well)) + db.session.flush() + + def seed_database(force: bool = False) -> None: if Doctor.query.count() > 0 and not force: return @@ -1167,6 +1234,9 @@ def seed_database(force: bool = False) -> None: _finish_hubs(Hospital.query.order_by(Hospital.id).all(), Practice.query.order_by(Practice.id).all()) _topup_languages(doctors) _sync_public_payer_rows(doctors) + _assign_office_lines(doctors) + _unify_practice_hours() + _bound_perspective_votes(doctors) for doctor in doctors: del doctor._slot del doctor._practice diff --git a/sites/webmd_doctor/templates/results.html b/sites/webmd_doctor/templates/results.html index ebf06fda..bc48ed15 100644 --- a/sites/webmd_doctor/templates/results.html +++ b/sites/webmd_doctor/templates/results.html @@ -7,7 +7,7 @@

{{ term }} near {
{{ page.total | plural_word('Result') }}

{% if loc.fallback %}
Showing providers near Newark, DE 19711 — we couldn't match '{{ loc.input }}'.
{% endif %} - {% if unmatched_q %}
Showing all providers near {{ heading_place }} — we couldn't match '{{ unmatched_q }}'. Search by specialty, condition, procedure or insurance carrier.
{% endif %} + {% if unmatched_q %}
We couldn't find any providers matching '{{ unmatched_q }}' near {{ heading_place }}. Try a specialty, condition, procedure or insurance carrier, or browse by specialty.
{% endif %} {% include "_filter_bar.html" %}
WebMD PATIENT'S CHOICEPatients' Choice awards are assigned based on patient satisfaction ratings for key specialties in select geographic locations.
@@ -16,6 +16,8 @@

{{ term }} near { {% for row in page.rows %}{% with doctor=row.doctor, distance=row.distance %}{% include "_physician_card.html" %}{% endwith %}{% endfor %}

{% include "_pagination.html" %} + {% elif unmatched_q %} +
No results for '{{ unmatched_q }}'. Browse by specialty
{% else %}
No providers matched these filters near {{ heading_place }}. Try widening the distance or clearing a filter.
{% endif %} From 3859db925c7f12600a269e1ffa1f1ebd6dc8b174 Mon Sep 17 00:00:00 2001 From: evanz37 <89922710+evanz37@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:09:14 -0400 Subject: [PATCH 11/21] fix(webmd_doctor): audit-C fixes (task wording, booking reference, specialty-consistent condition lists) - T15 ques names the real controls (Award Winning Hospitals > WebMD Patient's Choice); T4 ques uses the on-page heading "Certifications, License, & Education" (upstream label). - Booking reference is eight base-32 chars packed injectively from (row id, doctor id, office index, day, slot) with fixed constants - two bookings can never share a reference, and a first booking after a reset is no longer the same string for every doctor. - Most-Treated / Top-20 conditions and procedures draw only from the doctor's own specialty plus its SECONDARY_CHOICES pool (RELATED_SPECIALTIES removed); under-5-years bucket is 2-4 years; five training-institution names that doubled as hub hospitals replaced. - "1 Year Experience" pluralised on cards, profile and hub mini-cards. - <=720 px CSS: header nav, review controls, footer badges wrap; promo panel and location blocks stay inside the viewport. - Re-frozen seed (PYTHONHASHSEED 0 and 1 identical; images byte-identical to the shipped HF tarball); EXPECTED_COUNTS + README row counts updated; re-picked targets for tasks 4, 11 and 14 after the RNG reshuffle. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01S8Mt6G2qwvqncZiKyVC5pR --- sites/webmd_doctor/README.md | 6 +- sites/webmd_doctor/app.py | 25 ++++++-- sites/webmd_doctor/seed_data.py | 60 ++++++++----------- sites/webmd_doctor/static/css/site.css | 11 ++++ sites/webmd_doctor/tasks.jsonl | 6 +- sites/webmd_doctor/templates/_mini_card.html | 2 +- .../templates/_physician_card.html | 2 +- sites/webmd_doctor/templates/doctor.html | 4 +- 8 files changed, 66 insertions(+), 50 deletions(-) diff --git a/sites/webmd_doctor/README.md b/sites/webmd_doctor/README.md index 444381e8..40713c03 100644 --- a/sites/webmd_doctor/README.md +++ b/sites/webmd_doctor/README.md @@ -20,10 +20,10 @@ The Docker build regenerates `instance_seed/webmd_doctor.db` from `seed_data.py` |---|---|---|---| | doctors | 226 (202 within 40 mi of Newark, DE 19711 + 24 in Baltimore, MD) | locations | 348 | | specialties | 10 | conditions / procedures / expertise_areas | 40 / 30 / 40 | -| doctor_conditions / doctor_procedures / doctor_expertise | 1677 / 1252 / 686 | insurers / insurance_plans / doctor_insurances | 12 / 28 / 2208 | +| doctor_conditions / doctor_procedures / doctor_expertise | 1587 / 1105 / 667 | insurers / insurance_plans / doctor_insurances | 12 / 28 / 2233 | | cities / city_zips | 8 / 24 | hospitals / practices | 12 / 30 | -| reviews | 1206 | doctor_perspectives | 1582 | -| certifications / licenses / education | 296 / 316 / 567 | awards / doctor_languages | 50 / 351 | +| reviews | 1202 | doctor_perspectives | 1582 | +| certifications / licenses / education | 294 / 309 / 560 | awards / doctor_languages | 50 / 366 | | users | 4 | saved_providers / appointment_requests / user_reviews | 4 / 1 / 1 | Benchmark accounts: `alice.j`, `bob.c`, `carol.d`, `david.k` `@test.com`, password `TestPass123!`. diff --git a/sites/webmd_doctor/app.py b/sites/webmd_doctor/app.py index 2d304a08..831f7c5f 100644 --- a/sites/webmd_doctor/app.py +++ b/sites/webmd_doctor/app.py @@ -702,16 +702,31 @@ def haversine_miles(lat1: float, lon1: float, lat2: float, lon2: float) -> float return 2 * radius * math.asin(math.sqrt(a)) -def confirmation_reference(row_id: int) -> str: - """Fixed affine permutation of the row id rendered as six base-32 characters.""" - value = (row_id * 0x5A7B3 + 0x2C9F1) % (32**6) +def confirmation_reference(row_id: int, doctor_id: int, office_index: int, slot_date: date, slot_time: str) -> str: + """Eight base-32 characters packed from the whole booking row with fixed constants (no randomness). + + 40 bits: row id (23) | doctor id (10) | office index within the doctor's Locations list (2) | + booking-grid day and time slot (5), then one odd-multiplier affine step, which is a bijection + on 40 bits. The packing is injective for row ids below 2**23 and doctor ids below 2**10, so + two rows in one database never share a reference and the same row id booked for another + doctor, office or slot yields a different string. + """ + day_index = next((i for i, (_a, _s, day, _l) in enumerate(BOOKING_DAYS) if day == slot_date), 0) + slot_index = BOOKING_SLOTS.index(slot_time) if slot_time in BOOKING_SLOTS else 0 + packed = ((row_id % 2**23) << 17) | ((doctor_id % 2**10) << 7) | ((office_index % 4) << 5) | (day_index * len(BOOKING_SLOTS) + slot_index) + value = (packed * 0x5A7B3A2B7E15 + 0x2C9F19E37) % (2**40) chars = [] - for _ in range(6): + for _ in range(8): chars.append(BASE32_ALPHABET[value % 32]) value //= 32 return "WMD-" + "".join(reversed(chars)) +def office_index(doctor: Doctor, location: Location) -> int: + """Position of `location` in the doctor's Locations list (primary office first).""" + return [row.id for row in doctor.locations].index(location.id) + + def safe_next(raw: str | None) -> str | None: """Return a same-origin relative path (with query) or ``None``.""" if not raw or len(raw) > 2048 or any(ord(char) < 32 for char in raw): @@ -1336,7 +1351,7 @@ def book_appointment(slug: str): ) db.session.add(booking) db.session.flush() - booking.reference = confirmation_reference(booking.id) + booking.reference = confirmation_reference(booking.id, doctor.id, office_index(doctor, location), slot_date, slot_time) db.session.commit() return render_template("book_confirm.html", doctor=doctor, booking=booking) return render_template( diff --git a/sites/webmd_doctor/seed_data.py b/sites/webmd_doctor/seed_data.py index 07f2a033..18779ad1 100644 --- a/sites/webmd_doctor/seed_data.py +++ b/sites/webmd_doctor/seed_data.py @@ -58,6 +58,7 @@ UserReview, app, confirmation_reference, + office_index, db, ) @@ -100,17 +101,17 @@ "practices": 30, "doctors": 226, "locations": 348, - "doctor_conditions": 1677, - "doctor_procedures": 1252, - "doctor_expertise": 686, - "doctor_insurances": 2208, - "reviews": 1206, + "doctor_conditions": 1587, + "doctor_procedures": 1105, + "doctor_expertise": 667, + "doctor_insurances": 2233, + "reviews": 1202, "doctor_perspectives": 1582, - "certifications": 296, - "licenses": 316, - "education": 567, + "certifications": 294, + "licenses": 309, + "education": 560, "awards": 50, - "doctor_languages": 351, + "doctor_languages": 366, "users": 4, "saved_providers": 4, "appointment_requests": 1, @@ -144,19 +145,7 @@ ("Internal Medicine", "internal-medicine", "Internist", "Internists", "American Board of Internal Medicine", "Internal Medicine", "Geriatric Medicine", "Internists are primary care physicians for adults, focusing on prevention and the diagnosis and management of chronic conditions."), ] -# Specialties whose conditions / procedures a doctor may also list (cross-field consistency). -RELATED_SPECIALTIES = { - "Dermatology": ["Internal Medicine", "Family Medicine"], - "Cardiovascular Disease": ["Internal Medicine", "Family Medicine"], - "Family Medicine": ["Internal Medicine", "Pediatrics", "Dermatology", "Cardiovascular Disease", "Gastroenterology", "Psychiatry", "Obstetrics & Gynecology"], - "Neurology": ["Psychiatry", "Internal Medicine"], - "Orthopedic Surgery": ["Family Medicine", "Internal Medicine"], - "Gastroenterology": ["Internal Medicine", "Family Medicine"], - "Psychiatry": ["Neurology", "Family Medicine", "Internal Medicine"], - "Obstetrics & Gynecology": ["Family Medicine", "Internal Medicine"], - "Pediatrics": ["Family Medicine", "Internal Medicine"], - "Internal Medicine": ["Family Medicine", "Cardiovascular Disease", "Gastroenterology", "Dermatology", "Neurology", "Psychiatry"], -} +# Secondary specialties: the only other pool a doctor's conditions / procedures may draw from. SECONDARY_CHOICES = { "Dermatology": ["Internal Medicine"], "Cardiovascular Disease": ["Internal Medicine"], @@ -363,12 +352,12 @@ "Laurel Highlands Medical College", "Tuckahoe College of Osteopathic Medicine", ] TRAINING_HOSPITALS = [ - "Brandywine Valley Hospital", "Patapsco Harbor Medical Center", "Chester Valley Medical Center", + "Tuscarora Valley Hospital", "Elk Neck Medical Center", "Cumberland Ridge Hospital", "Allegheny Ridge Medical Center", "Harbor Point University Hospital", "Susquehanna General Hospital", "Lenape Valley Medical Center", "Great Falls University Hospital", "Tidewater Regional Medical Center", "Monocacy General Hospital", "Schuylkill Medical Center", "Piedmont Atlantic Hospital", "Cape Henlopen Medical Center", "Severn River Hospital", "Blue Ridge Regional Medical Center", - "Christina Creek Medical Center", "Riverfront General Hospital", "Shenandoah Memorial Hospital", + "Delmarva Bay Medical Center", "Pocono Summit Hospital", "Shenandoah Memorial Hospital", "Wyoming Valley Medical Center", "Kittatinny Regional Hospital", "Conestoga General Hospital", "Nanticoke Memorial Medical Center", "Laurel Highlands Hospital", "Rappahannock University Hospital", ] @@ -655,7 +644,7 @@ def _assign_quotas(slots: list[dict]) -> None: ratings += multiset([(round(1.0 + 0.4 * i, 1), 5) for i in range(2)]) ratings = ratings[:24] + ratings[24:30] + ratings[30:] RNG.shuffle(ratings) - years = multiset([(RNG.randint(1, 4), 1) for _ in range(22)] + [(RNG.randint(5, 14), 1) for _ in range(50)] + years = multiset([(max(2, RNG.randint(1, 4)), 1) for _ in range(22)] + [(RNG.randint(5, 14), 1) for _ in range(50)] + [(RNG.randint(15, 19), 1) for _ in range(36)] + [(RNG.randint(20, 24), 1) for _ in range(34)] + [(RNG.randint(25, 29), 1) for _ in range(30)] + [(RNG.randint(30, 42), 1) for _ in range(28)]) new_patients = multiset([(True, 150), (False, 50)]) @@ -854,7 +843,7 @@ def _build_doctor_children(doctors: list[Doctor], vocab: dict) -> None: procedures = vocab["procedures"] areas = vocab["areas"] insurers = vocab["insurers"] - other_specs = {name: list(RELATED_SPECIALTIES[name]) for name, *_rest in SPECIALTIES} + secondary_specs = {name: list(SECONDARY_CHOICES[name]) for name, *_rest in SPECIALTIES} tiers = ["Similar", "More Often", "More Than Most"] used_review_texts: set[str] = set() for doctor in doctors: @@ -862,18 +851,19 @@ def _build_doctor_children(doctors: list[Doctor], vocab: dict) -> None: slot = doctor._slot practice = doctor._practice city_name = slot["city"] - # conditions: 2 own + 4-8 from other specialties - own = RNG.sample(conditions[spec_name], 2) - related_pool = [c for other in other_specs[spec_name] for c in conditions[other]] - extras = RNG.sample(related_pool, RNG.randint(4, min(7, len(related_pool)))) + # conditions: every condition of the doctor's own specialty first, then 2-4 from the + # specialty's secondary pool (SECONDARY_CHOICES) - never another specialty's list + own = RNG.sample(conditions[spec_name], len(conditions[spec_name])) + secondary_pool = [c for other in secondary_specs[spec_name] for c in conditions[other]] + extras = RNG.sample(secondary_pool, RNG.randint(2, min(4, len(secondary_pool)))) ordered = own + extras for position, condition in enumerate(ordered, start=1): tier = RNG.choices(tiers, weights=[35, 35, 30])[0] db.session.add(DoctorCondition(doctor_id=doctor.id, condition_id=condition.id, tier=tier, position=position)) - # procedures: 1-2 own + 3-5 other - own_procs = RNG.sample(procedures[spec_name], RNG.randint(1, 2)) - related_procs = [q for other in other_specs[spec_name] for q in procedures[other]] - other_procs = RNG.sample(related_procs, RNG.randint(3, min(5, len(related_procs)))) + # procedures: every own procedure + 1-3 from the secondary pool + own_procs = RNG.sample(procedures[spec_name], len(procedures[spec_name])) + secondary_procs = [q for other in secondary_specs[spec_name] for q in procedures[other]] + other_procs = RNG.sample(secondary_procs, RNG.randint(1, min(3, len(secondary_procs)))) for position, procedure in enumerate(own_procs + other_procs, start=1): tier = RNG.choices(tiers, weights=[35, 35, 30])[0] db.session.add(DoctorProcedure(doctor_id=doctor.id, procedure_id=procedure.id, tier=tier, position=position)) @@ -1281,7 +1271,7 @@ def nth_doctor(spec_name: str, n: int, **filters) -> Doctor: ) db.session.add(booking) db.session.flush() - booking.reference = confirmation_reference(booking.id) + booking.reference = confirmation_reference(booking.id, enhanced.id, office_index(enhanced, enhanced.primary_location), booking.slot_date, booking.slot_time) reviewed = nth_doctor("Family Medicine", 6) db.session.add(UserReview( user_id=alice.id, diff --git a/sites/webmd_doctor/static/css/site.css b/sites/webmd_doctor/static/css/site.css index dced8db7..afa7b70b 100644 --- a/sites/webmd_doctor/static/css/site.css +++ b/sites/webmd_doctor/static/css/site.css @@ -620,4 +620,15 @@ details.review-form > summary::-webkit-details-marker { display: none; } .grid-days { grid-template-columns: repeat(2, 1fr); } .book-page { padding: 20px 16px 40px; } .site-footer .top { flex-direction: column; } + /* narrow phones: nothing may push the page wider than the viewport */ + .header-nav { flex-wrap: wrap; row-gap: 0; } + .promo-photo { left: 0; width: min(360px, calc(100vw - 32px)); height: min(360px, calc(100vw - 32px)); } + .review-head, .review-controls { flex-wrap: wrap; } + .review-controls .fake-select, .review-controls .fake-search { min-width: 0; } + .legal { flex-wrap: wrap; } + .badges { flex-wrap: wrap; } + .loc, .loc.stacked { grid-template-columns: 1fr; } + .loc .map { width: auto; max-width: 100%; } + .tabs { overflow-x: auto; } + .loc > div { min-width: 0; overflow-wrap: anywhere; } } diff --git a/sites/webmd_doctor/tasks.jsonl b/sites/webmd_doctor/tasks.jsonl index 7207749a..a97c6255 100644 --- a/sites/webmd_doctor/tasks.jsonl +++ b/sites/webmd_doctor/tasks.jsonl @@ -2,7 +2,7 @@ {"web_name": "WebMD Doctor", "id": "WebMD Doctor--1", "ques": "Find Dr. Julian Zamora, a Cardiovascular Disease specialist whose primary office is in Wilmington, DE. Report the NPI number shown on the profile and the languages spoken.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Cardiologist&sids=29236"} {"web_name": "WebMD Doctor", "id": "WebMD Doctor--2", "ques": "Search for Family Medicine doctors near Newark, DE 19711 and open Dr. Ruth Thackeray's profile. Report the phone number listed for the primary office and that office's Saturday hours.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Family+Medicine"} {"web_name": "WebMD Doctor", "id": "WebMD Doctor--3", "ques": "Open the profile of Dr. Mateo Alvarado, a Neurologist in West Chester, PA. Besides the primary office, the Locations section lists one other office. Report that office's name and street address.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/neurology/pennsylvania/west-chester"} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--4", "ques": "Find Dr. Naomi Merriweather, an Orthopedic Surgeon in Elkton, MD. From the Certifications, License & Education section, report the board that certified them, the certification year, and the institution where they completed their residency.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/orthopedic-surgery/maryland/elkton"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--4", "ques": "Find Dr. Rafael Khoury, an Orthopedic Surgeon in Elkton, MD. From the Certifications, License, & Education section, report the board that certified them, the certification year, and the institution where they completed their residency.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/orthopedic-surgery/maryland/elkton"} {"web_name": "WebMD Doctor", "id": "WebMD Doctor--5", "ques": "Search for Gastroenterologists near Newark, DE 19711 and open Dr. Caroline Danforth's profile. Among the five most-treated conditions shown, exactly one is marked \"More Than Most\". Which condition is it, and which condition is listed first under \"View Top 20\"?", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Gastroenterologist"} {"web_name": "WebMD Doctor", "id": "WebMD Doctor--6", "ques": "Open the profile of Dr. Fatima Jensen, a Psychiatrist in Media, PA, and read all of their reviews. What is the date shown on the oldest review, and what star rating did that reviewer give?", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/psychiatry/pennsylvania/media"} {"web_name": "WebMD Doctor", "id": "WebMD Doctor--7", "ques": "Find Dr. Lillian Acosta, an Obstetrics & Gynecology specialist in Salem, NJ. Which of the seven Patients' Perspective criteria received the most needs-improvement votes, and what average wait time is shown on the profile?", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/obstetrics-gynecology/new-jersey/salem"} @@ -12,8 +12,8 @@ {"web_name": "WebMD Doctor", "id": "WebMD Doctor--11", "ques": "Set the distance to 10 miles from Newark, DE 19711, search for Family Medicine doctors and sort by Number of Ratings. Open the profile of the doctor with the second-highest number of ratings and report their NPI number and the hospital they are affiliated with.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Family+Medicine"} {"web_name": "WebMD Doctor", "id": "WebMD Doctor--12", "ques": "From the Find Providers by Specialty menu open Cardiovascular Disease, then Pennsylvania, then West Chester. Filter to doctors rated 4 stars or higher. Open the profile of the only male doctor in that list and report his fellowship institution and the year he completed it.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/cardiovascular-disease/pennsylvania/west-chester"} {"web_name": "WebMD Doctor", "id": "WebMD Doctor--13", "ques": "Among Dermatologists in Wilmington, DE, Dr. Gregory Greenwood and Dr. Dana Valdez both accept Blue Cross Blue Shield. Which of the two graduated from medical school earlier? Report that doctor's name and graduation year.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/dermatology/delaware/wilmington"} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--14", "ques": "Open the hospital page for White Clay Regional Hospital (Find a Facility > Hospitals > Delaware). Two of its listed physicians are Neurologists; open both profiles. Which one was board certified more recently? Report that doctor's name and the certification year.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/hospitals/delaware"} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--15", "ques": "Open the Choice Awards page and view the WebMD Patient's Choice recipients. Find the recipient who practices Orthopedic Surgery in Media, PA, open their profile, then open the practice page linked from their primary office. Report the practice's website address and its Saturday hours.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/choice-awards/awardrecipients?award-class=patient"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--14", "ques": "Open the hospital page for Christina Creek Medical Center (Find a Facility > Hospitals > Delaware). Two of its listed physicians are Neurologists; open both profiles. Which one was board certified more recently? Report that doctor's name and the certification year.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/hospitals/delaware"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--15", "ques": "From the header, open Award Winning Hospitals, then view the WebMD Patient's Choice recipients. Find the recipient who practices Orthopedic Surgery in Media, PA, open their profile, then open the practice page linked from their primary office. Report the practice's website address and its Saturday hours.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/choice-awards/awardrecipients?award-class=patient"} {"web_name": "WebMD Doctor", "id": "WebMD Doctor--16", "ques": "Log in with the demo account (email: bob.c@test.com, password: TestPass123!), search for Pediatricians near Newark, DE 19711, open the profile of Dr. Anita Castellano and save the provider. Then open Saved Providers and confirm Dr. Castellano appears there.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Pediatrician"} {"web_name": "WebMD Doctor", "id": "WebMD Doctor--17", "ques": "Log in as carol.d@test.com (password: TestPass123!). Open the profile of Dr. Sarah Keller, a Cardiovascular Disease specialist in Newark, DE, and request an appointment as a New Patient at the Riverfront Heart & Vascular - Wellness Center office on Mon, Sep 14 at 10:30 AM. Report the confirmation reference shown after submitting.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Cardiologist&sids=29236"} {"web_name": "WebMD Doctor", "id": "WebMD Doctor--18", "ques": "Log in as david.k@test.com (password: TestPass123!). Find Dr. Tariq Huang, a Dermatologist in Elkton, MD, and leave a 4-star review with the text \"Short wait and a clear explanation of my treatment options.\" Confirm the profile now shows your review as Pending review.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/reviews-guidelines"} diff --git a/sites/webmd_doctor/templates/_mini_card.html b/sites/webmd_doctor/templates/_mini_card.html index 4b651994..467d1c0c 100644 --- a/sites/webmd_doctor/templates/_mini_card.html +++ b/sites/webmd_doctor/templates/_mini_card.html @@ -7,6 +7,6 @@ {% with rating=doctor.avg_rating, small=true %}{% include "_stars.html" %}{% endwith %} ({{ doctor.ratings_count | plural_word('Rating') }}) -
{{ doctor.years_experience }} Years Exp{% if doctor.awards %} {{ doctor.awards | length | plural_word('Award') }}{% endif %}
+
{{ doctor.years_experience | plural_word('Year') }} Exp{% if doctor.awards %} {{ doctor.awards | length | plural_word('Award') }}{% endif %}
{% if filled %}View Profile{% else %}View Profile{% endif %} diff --git a/sites/webmd_doctor/templates/_physician_card.html b/sites/webmd_doctor/templates/_physician_card.html index 4c9032b1..8894318d 100644 --- a/sites/webmd_doctor/templates/_physician_card.html +++ b/sites/webmd_doctor/templates/_physician_card.html @@ -16,7 +16,7 @@

{{ doctor.displa
    {% for line in doctor.award_lines %}
  • {{ line }}
  • {% endfor %} {% if doctor.is_enhanced and doctor.callout_label %}
  • {{ doctor.callout_label }}
  • {% endif %} -
  • {{ doctor.years_experience }} Years Experience
  • +
  • {{ doctor.years_experience | plural_word('Year') }} Experience
  • {% if doctor.is_enhanced %}
  • {{ 'Accepting New Patients' if doctor.accepting_new_patients else 'Not Accepting New Patients' }}
  • {% endif %}
{% if doctor.is_enhanced and doctor.virtual_visit %}Telehealth Available{% endif %} diff --git a/sites/webmd_doctor/templates/doctor.html b/sites/webmd_doctor/templates/doctor.html index ddd510c0..0b336e0d 100644 --- a/sites/webmd_doctor/templates/doctor.html +++ b/sites/webmd_doctor/templates/doctor.html @@ -29,7 +29,7 @@

{{ doctor.display_name }}

{% endif %}
{{ primary.phone }}
    -
  • {{ doctor.years_experience }} Years Experience
  • +
  • {{ doctor.years_experience | plural_word('Year') }} Experience
  • {{ primary.name }}
    {{ primary.address_line }}{% if doctor.other_location_count %} {{ doctor.other_location_count | plural_word('other location') }}{% endif %}
  • {% if doctor.hospital %}
  • {{ doctor.hospital.name }}
  • {% endif %} {% for line in doctor.award_lines %}
  • {{ line }}
  • {% endfor %} @@ -362,7 +362,7 @@

    NPI Number

    {{ colleague.primary_specialty.name }}
    -
    {{ colleague.years_experience }} Years Experience
    +
    {{ colleague.years_experience | plural_word('Year') }} Experience
    {% with rating=colleague.avg_rating, small=true %}{% include "_stars.html" %}{% endwith %} {% else %}

    No other providers listed.

    {% endfor %} From 273ca14de59a4dfa8c09a4275c813a4e60200688 Mon Sep 17 00:00:00 2001 From: evanz37 <89922710+evanz37@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:05:40 -0400 Subject: [PATCH 12/21] fix(webmd_doctor): audit-D realism fixes (curated specialty pools, training timelines, per-city streets) - SECONDARY_CONDITIONS / SECONDARY_PROCEDURES: curated per-specialty pools (9-10 conditions, 6-8 procedures incl. the primaries); Top-20 lists draw only from the primaries + the doctor's own specialty pool (conditions 40 -> 82, procedures 30 -> 62) - TRAINING + training_plan(): residency length per specialty after the MD, fellowship (Cardio/GI 3 y required, optional 1-3 y elsewhere), board = end of training + 0/1, years of experience = 2026 - board year; subspecialty board only after a fellowship - SECONDARY_STREETS per city; every street name belongs to exactly one city (Deer Park Digestive Health moves from E Main St to Library Ave) - _bound_review_stars(): review means stay within 1.0 of the profile average (RNG-free) - doctor stream unchanged (names, slugs, NPIs, cities, ratings, years, offices, phones); EXPECTED_COUNTS + README rows updated; images unchanged (list digest e1b14d92) - tasks: T4 -> Dr. Charles Villanueva, T11 reports NPI + residency institution, T13 pair -> Greenwood vs Dr. Emerson Huang, T14 compares the two Psychiatrists Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YKXfhRp7xMmrgixw5Ccohe --- sites/webmd_doctor/README.md | 8 +- sites/webmd_doctor/seed_data.py | 184 ++++++++++++++++++++++++++------ sites/webmd_doctor/tasks.jsonl | 8 +- 3 files changed, 162 insertions(+), 38 deletions(-) diff --git a/sites/webmd_doctor/README.md b/sites/webmd_doctor/README.md index 40713c03..55411608 100644 --- a/sites/webmd_doctor/README.md +++ b/sites/webmd_doctor/README.md @@ -19,11 +19,11 @@ The Docker build regenerates `instance_seed/webmd_doctor.db` from `seed_data.py` | Model | Rows | Model | Rows | |---|---|---|---| | doctors | 226 (202 within 40 mi of Newark, DE 19711 + 24 in Baltimore, MD) | locations | 348 | -| specialties | 10 | conditions / procedures / expertise_areas | 40 / 30 / 40 | -| doctor_conditions / doctor_procedures / doctor_expertise | 1587 / 1105 / 667 | insurers / insurance_plans / doctor_insurances | 12 / 28 / 2233 | +| specialties | 10 | conditions / procedures / expertise_areas | 82 / 62 / 40 | +| doctor_conditions / doctor_procedures / doctor_expertise | 1800 / 1316 / 674 | insurers / insurance_plans / doctor_insurances | 12 / 28 / 2258 | | cities / city_zips | 8 / 24 | hospitals / practices | 12 / 30 | -| reviews | 1202 | doctor_perspectives | 1582 | -| certifications / licenses / education | 294 / 309 / 560 | awards / doctor_languages | 50 / 366 | +| reviews | 1227 | doctor_perspectives | 1582 | +| certifications / licenses / education | 307 / 295 / 595 | awards / doctor_languages | 50 / 363 | | users | 4 | saved_providers / appointment_requests / user_reviews | 4 / 1 / 1 | Benchmark accounts: `alice.j`, `bob.c`, `carol.d`, `david.k` `@test.com`, password `TestPass123!`. diff --git a/sites/webmd_doctor/seed_data.py b/sites/webmd_doctor/seed_data.py index 18779ad1..25d1cb9c 100644 --- a/sites/webmd_doctor/seed_data.py +++ b/sites/webmd_doctor/seed_data.py @@ -90,8 +90,8 @@ # Filled in from the frozen seed; ensure_seed_database refuses partial DBs. EXPECTED_COUNTS = { "specialties": 10, - "conditions": 40, - "procedures": 30, + "conditions": 82, + "procedures": 62, "expertise_areas": 40, "insurers": 12, "insurance_plans": 28, @@ -101,17 +101,17 @@ "practices": 30, "doctors": 226, "locations": 348, - "doctor_conditions": 1587, - "doctor_procedures": 1105, - "doctor_expertise": 667, - "doctor_insurances": 2233, - "reviews": 1202, + "doctor_conditions": 1800, + "doctor_procedures": 1316, + "doctor_expertise": 674, + "doctor_insurances": 2258, + "reviews": 1227, "doctor_perspectives": 1582, - "certifications": 294, - "licenses": 309, - "education": 560, + "certifications": 307, + "licenses": 295, + "education": 595, "awards": 50, - "doctor_languages": 366, + "doctor_languages": 363, "users": 4, "saved_providers": 4, "appointment_requests": 1, @@ -146,6 +146,7 @@ "Internists are primary care physicians for adults, focusing on prevention and the diagnosis and management of chronic conditions."), ] # Secondary specialties: the only other pool a doctor's conditions / procedures may draw from. +# Secondary specialty a doctor may additionally list (profile "secondary specialty" only). SECONDARY_CHOICES = { "Dermatology": ["Internal Medicine"], "Cardiovascular Disease": ["Internal Medicine"], @@ -158,6 +159,52 @@ "Pediatrics": ["Family Medicine", "Internal Medicine"], "Internal Medicine": ["Family Medicine", "Cardiovascular Disease", "Gastroenterology"], } +# Curated per-specialty secondary pools (audit D): conditions / procedures a practitioner of +# that specialty plausibly treats besides the CONDITIONS / PROCEDURES primaries. A doctor's +# Top-20 lists draw ONLY from the primaries + this pool of the doctor's own specialty. A name +# that is another specialty's primary reuses that row; a secondary-only name becomes a row +# owned by the first specialty (SPECIALTIES order) that lists it. +SECONDARY_CONDITIONS = { + "Dermatology": ["Skin Cancer", "Warts", "Hair Loss (Alopecia)", "Hives (Urticaria)", "Contact Dermatitis"], + "Cardiovascular Disease": ["High Cholesterol", "Heart Valve Disease", "Peripheral Artery Disease", "Cardiomyopathy", "Chest Pain (Angina)"], + "Family Medicine": ["Hypertension", "Hypothyroidism", "Urinary Tract Infection", "Allergic Rhinitis", "Upper Respiratory Infection", "Obesity"], + "Neurology": ["Stroke", "Peripheral Neuropathy", "Alzheimer's Disease and Dementia", "Essential Tremor", "Carpal Tunnel Syndrome"], + "Orthopedic Surgery": ["Back Pain", "Carpal Tunnel Syndrome", "Meniscus Tear", "Tennis Elbow", "Plantar Fasciitis", "Herniated Disc"], + "Gastroenterology": ["Anemia", "Ulcerative Colitis", "Gallstones", "Hepatitis C", "Peptic Ulcer Disease", "Hemorrhoids"], + "Psychiatry": ["Post-Traumatic Stress Disorder", "Obsessive-Compulsive Disorder", "Insomnia", "Schizophrenia", "Panic Disorder", "Substance Use Disorder"], + "Obstetrics & Gynecology": ["Pregnancy", "Abnormal Uterine Bleeding", "Infertility", "Ovarian Cysts", "Pelvic Inflammatory Disease", "Urinary Tract Infection"], + "Pediatrics": ["ADHD", "Allergic Rhinitis", "Bronchiolitis", "Eczema", "Upper Respiratory Infection", "Developmental Delay"], + "Internal Medicine": ["Type 2 Diabetes", "High Cholesterol", "Hypertension", "Obesity", "COPD", "Gout"], +} +SECONDARY_PROCEDURES = { + "Dermatology": ["Laser Skin Treatment", "Chemical Peel", "Botox Cosmetic Injection"], + "Cardiovascular Disease": ["Holter Monitoring", "Coronary Angioplasty and Stent", "Cardioversion", "Pacemaker Implantation"], + "Family Medicine": ["Blood Pressure Screening", "Diabetes Management", "Skin Lesion Removal", "Sports Physical", "Ear Wax Removal"], + "Neurology": ["Nerve Conduction Study", "Botulinum Toxin Injection for Migraine", "Sleep Study Interpretation"], + "Orthopedic Surgery": ["Total Knee Replacement", "Fracture Repair", "Rotator Cuff Repair", "Joint Injection"], + "Gastroenterology": ["Polypectomy", "Hemorrhoid Banding", "Liver Biopsy", "Esophageal Dilation"], + "Psychiatry": ["Psychiatric Evaluation", "Cognitive Behavioral Therapy", "Electroconvulsive Therapy"], + "Obstetrics & Gynecology": ["Colposcopy", "Hysterectomy", "Cesarean Section", "Endometrial Biopsy", "Tubal Ligation"], + "Pediatrics": ["Hearing Screening", "Newborn Care Visit", "Sports Physical", "Flu Vaccination"], + "Internal Medicine": ["Annual Physical Exam", "Flu Vaccination", "Preventive Health Screening", "Lung Function Test (Spirometry)"], +} +# Training timeline per specialty (audit D): residency length in years after the MD year +# (residency starts the year after graduation), fellowship length and whether every +# practitioner completes one. Board certification = end of training + 0/1 year; years of +# experience = 2026 - certification year. +TRAINING = { + # specialty: (residency years, fellowship years, fellowship required) + "Dermatology": (4, 1, False), + "Cardiovascular Disease": (3, 3, True), + "Family Medicine": (3, 1, False), + "Neurology": (4, 2, False), + "Orthopedic Surgery": (5, 1, False), + "Gastroenterology": (3, 3, True), + "Psychiatry": (4, 2, False), + "Obstetrics & Gynecology": (4, 3, False), + "Pediatrics": (3, 3, False), + "Internal Medicine": (3, 1, False), +} CONDITIONS = { "Dermatology": ["Acne", "Eczema", "Psoriasis", "Rosacea"], "Cardiovascular Disease": ["Coronary Artery Disease", "Atrial Fibrillation", "Heart Failure", "Hypertension"], @@ -281,7 +328,7 @@ ("White Clay", "Cardiovascular Disease", "620 Churchmans Rd Ste 110", "19702"), ("Iron Hill", "Family Medicine", "2600 Glasgow Ave Ste 116", "19702"), ("Glasgow Pike", "Neurology", "500 Peoples Plz Ste 230", "19702"), - ("Deer Park", "Gastroenterology", "255 E Main St Ste 200", "19711"), + ("Deer Park", "Gastroenterology", "255 Library Ave Ste 200", "19711"), ("Pike Creek", "Pediatrics", "3401 Papermill Rd Ste 5", "19711"), ("Main Street", None, "112 S Main St Fl 3", "19711"), ("Ogletown", None, "4051 Ogletown Rd Ste 101", "19713"), @@ -324,7 +371,17 @@ ], } SECONDARY_OFFICE_TAGS = ["North Office", "Medical Arts Building", "Outpatient Center", "Professional Plaza", "Annex", "Satellite Office", "Pavilion", "Wellness Center"] -SECONDARY_STREETS = ["Concord Pike", "Kirkwood Hwy", "Limestone Rd", "Marsh Rd", "Naamans Rd", "Elkton Rd", "Pulaski Hwy", "Lancaster Pike", "Baltimore Pike", "Paoli Pike", "Salem Quinton Rd", "Route 40", "Silverside Rd", "Chestnut Hill Rd", "Old Baltimore Pike", "Eastern Ave", "Falls Rd", "Harford Rd"] +# Secondary-office street pools per city (audit D): a street name belongs to exactly one city. +SECONDARY_STREETS = { + "Newark": ["Kirkwood Hwy", "Elkton Rd", "Marrows Rd", "Chapel St", "Old Baltimore Pike"], + "Bear": ["Wrangle Hill Rd", "Red Lion Rd", "Porter Rd", "Route 72", "Bear Corbit Rd"], + "Wilmington": ["Concord Pike", "Silverside Rd", "Naamans Rd", "Marsh Rd", "Lancaster Pike"], + "Elkton": ["Route 40", "Bridge St", "Blue Ball Rd", "Singerly Rd", "Whitehall Rd"], + "Salem": ["Salem Quinton Rd", "Route 45", "Hancocks Bridge Rd", "Front St", "Fort Mott Rd"], + "West Chester": ["Paoli Pike", "Boot Rd", "Westtown Rd", "Phoenixville Pike", "Gay St"], + "Media": ["Baltimore Pike", "Providence Rd", "Middletown Rd", "Sandy Bank Rd", "Orange St"], + "Baltimore": ["Eastern Ave", "Falls Rd", "Harford Rd", "York Rd", "Charles St"], +} STREET_TYPES = ["Ste 100", "Ste 210", "Ste 305", "Bldg B", "Fl 2", "Ste 12", "Ste 400"] DAY_KEYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun") HOURS_PATTERNS = [ @@ -536,6 +593,26 @@ def _build_vocabulary() -> dict: areas[name] = [ExpertiseArea(name=a, specialty_id=specialties[name].id) for a in EXPERTISE[name]] db.session.add_all(conditions[name] + procedures[name] + areas[name]) db.session.flush() + # secondary-only names (not any specialty's primary) become rows owned by the first + # specialty that lists them; rows are created in SPECIALTIES order, then pool order + condition_by_name = {row.name: row for rows in conditions.values() for row in rows} + procedure_by_name = {row.name: row for rows in procedures.values() for row in rows} + secondary_conditions: dict[str, list[Condition]] = {} + secondary_procedures: dict[str, list[Procedure]] = {} + for name, *_rest in SPECIALTIES: + secondary_conditions[name] = [] + for label in SECONDARY_CONDITIONS[name]: + if label not in condition_by_name: + condition_by_name[label] = Condition(name=label, slug=slugify(label), specialty_id=specialties[name].id) + db.session.add(condition_by_name[label]) + secondary_conditions[name].append(condition_by_name[label]) + secondary_procedures[name] = [] + for label in SECONDARY_PROCEDURES[name]: + if label not in procedure_by_name: + procedure_by_name[label] = Procedure(name=label, slug=slugify(label), specialty_id=specialties[name].id) + db.session.add(procedure_by_name[label]) + secondary_procedures[name].append(procedure_by_name[label]) + db.session.flush() insurers: list[tuple[Insurer, int, list[InsurancePlan]]] = [] for name, slug, weight, plan_types in INSURERS: insurer = Insurer(name=name, slug=slug) @@ -553,7 +630,28 @@ def _build_vocabulary() -> dict: db.session.add_all([CityZip(city_id=city.id, zip=zip_code) for zip_code in zips]) cities[name] = city db.session.flush() - return {"specialties": specialties, "conditions": conditions, "procedures": procedures, "areas": areas, "insurers": insurers, "cities": cities} + return {"specialties": specialties, "conditions": conditions, "procedures": procedures, "areas": areas, "insurers": insurers, "cities": cities, + "secondary_conditions": secondary_conditions, "secondary_procedures": secondary_procedures} + + +def training_plan(spec_name: str, years: int, cert_delay: int, wants_fellowship: bool) -> dict: + """Backdate a doctor's training from the years-of-experience quota (pure function). + + certification = 2026 - years; end of training = certification - cert_delay (0/1); + fellowship (required for the specialty or chosen) ends the training; residency ends + fellowship_years earlier; the MD year is residency_years before the residency ends + (residency starts the year after the MD).""" + residency_years, fellowship_years, required = TRAINING[spec_name] + fellowship = required or wants_fellowship + cert_year = MIRROR_REFERENCE_DATE.year - years + end_of_training = cert_year - cert_delay + residency_year = end_of_training - (fellowship_years if fellowship else 0) + return { + "graduation_year": residency_year - residency_years, + "residency_year": residency_year, + "fellowship_year": end_of_training if fellowship else None, + "cert_year": cert_year, + } def _build_hospitals(cities: dict[str, City], used_phones: set[str]) -> dict[str, list[Hospital]]: @@ -714,7 +812,8 @@ def _build_doctors(vocab: dict, hospitals: dict[str, list[Hospital]], practices: city_name = slot["city"] specialty = specialties[spec_name] years = slot["years"] - graduation_year = MIRROR_REFERENCE_DATE.year - years - RNG.randint(0, 1) + cert_delay = RNG.randint(0, 1) # board certification 0/1 years after the end of training + graduation_year = MIRROR_REFERENCE_DATE.year - years # placeholder, backdated by training_plan below degree = "DO" if RNG.random() < 0.2 else "MD" secondary = None if RNG.random() < 0.3: @@ -814,11 +913,13 @@ def _build_doctors(vocab: dict, hospitals: dict[str, list[Hospital]], practices: other_practice = min(candidates, key=lambda p: (secondary_load.get(p.id, 0), p.id)) secondary_load[other_practice.id] = secondary_load.get(other_practice.id, 0) + 1 o_lat, o_lon = jitter(other_city.lat, other_city.lon) + # street: an 18-way draw (the size of the former shared pool, kept so the RNG stream + # is unchanged) folded onto the city's own pool location = Location( doctor_id=doctor.id, practice_id=other_practice.id, name=f"{other_practice.name} - {RNG.choice(SECONDARY_OFFICE_TAGS)}", - street=f"{RNG.randint(100, 4999)} {RNG.choice(SECONDARY_STREETS)} {RNG.choice(STREET_TYPES)}", + street=f"{RNG.randint(100, 4999)} {SECONDARY_STREETS[other_name][RNG.randrange(18) % len(SECONDARY_STREETS[other_name])]} {RNG.choice(STREET_TYPES)}", city_id=other_city.id, zip=RNG.choice([z.zip for z in other_city.zips]), lat=o_lat, @@ -831,6 +932,11 @@ def _build_doctors(vocab: dict, hospitals: dict[str, list[Hospital]], practices: ) apply_hours(location, RNG.choice(HOURS_PATTERNS)) db.session.add(location) + # training timeline (audit D): consumes no RNG (the optional-fellowship coin is the parity + # of the already-random NPI) so the name / slug / NPI / office stream is unchanged + plan = training_plan(spec_name, years, cert_delay, int(npi[-1]) % 2 == 0) + slot["plan"] = plan + doctor.graduation_year = plan["graduation_year"] doctors.append(doctor) db.session.flush() return doctors @@ -843,7 +949,8 @@ def _build_doctor_children(doctors: list[Doctor], vocab: dict) -> None: procedures = vocab["procedures"] areas = vocab["areas"] insurers = vocab["insurers"] - secondary_specs = {name: list(SECONDARY_CHOICES[name]) for name, *_rest in SPECIALTIES} + secondary_conditions = vocab["secondary_conditions"] + secondary_procedures = vocab["secondary_procedures"] tiers = ["Similar", "More Often", "More Than Most"] used_review_texts: set[str] = set() for doctor in doctors: @@ -851,19 +958,19 @@ def _build_doctor_children(doctors: list[Doctor], vocab: dict) -> None: slot = doctor._slot practice = doctor._practice city_name = slot["city"] - # conditions: every condition of the doctor's own specialty first, then 2-4 from the - # specialty's secondary pool (SECONDARY_CHOICES) - never another specialty's list + # conditions: every primary condition of the doctor's own specialty (shuffled) + 3-5 from + # the specialty's curated secondary pool (SECONDARY_CONDITIONS) - never any other list own = RNG.sample(conditions[spec_name], len(conditions[spec_name])) - secondary_pool = [c for other in secondary_specs[spec_name] for c in conditions[other]] - extras = RNG.sample(secondary_pool, RNG.randint(2, min(4, len(secondary_pool)))) + secondary_pool = secondary_conditions[spec_name] + extras = RNG.sample(secondary_pool, RNG.randint(3, min(5, len(secondary_pool)))) ordered = own + extras for position, condition in enumerate(ordered, start=1): tier = RNG.choices(tiers, weights=[35, 35, 30])[0] db.session.add(DoctorCondition(doctor_id=doctor.id, condition_id=condition.id, tier=tier, position=position)) - # procedures: every own procedure + 1-3 from the secondary pool + # procedures: every primary procedure + 2-4 from the curated secondary pool own_procs = RNG.sample(procedures[spec_name], len(procedures[spec_name])) - secondary_procs = [q for other in secondary_specs[spec_name] for q in procedures[other]] - other_procs = RNG.sample(secondary_procs, RNG.randint(1, min(3, len(secondary_procs)))) + secondary_procs = secondary_procedures[spec_name] + other_procs = RNG.sample(secondary_procs, RNG.randint(2, min(4, len(secondary_procs)))) for position, procedure in enumerate(own_procs + other_procs, start=1): tier = RNG.choices(tiers, weights=[35, 35, 30])[0] db.session.add(DoctorProcedure(doctor_id=doctor.id, procedure_id=procedure.id, tier=tier, position=position)) @@ -928,11 +1035,13 @@ def _build_doctor_children(doctors: list[Doctor], vocab: dict) -> None: doctor.callout_label = best_label if doctor.is_enhanced and doctor.ratings_count else None # certifications, licenses, education, languages spec_row = next(row for row in SPECIALTIES if row[0] == spec_name) - residency_year = min(doctor.graduation_year + RNG.randint(3, 5), MIRROR_REFERENCE_DATE.year) - cert_year = min(residency_year + RNG.randint(0, 2), MIRROR_REFERENCE_DATE.year) + plan = slot["plan"] # training timeline fixed in _build_doctors (training_plan) + residency_year = plan["residency_year"] + cert_year = plan["cert_year"] db.session.add(Certification(doctor_id=doctor.id, issuer=spec_row[4], cert_type=spec_row[5], year=cert_year)) - if RNG.random() < 0.3: - db.session.add(Certification(doctor_id=doctor.id, issuer=spec_row[4], cert_type=spec_row[6], year=min(cert_year + RNG.randint(1, 6), MIRROR_REFERENCE_DATE.year))) + # subspecialty certification only after a fellowship, 1-3 years after the primary board + if plan["fellowship_year"] is not None and RNG.random() < 0.5: + db.session.add(Certification(doctor_id=doctor.id, issuer=spec_row[4], cert_type=spec_row[6], year=min(cert_year + RNG.randint(1, 3), MIRROR_REFERENCE_DATE.year))) state_name = doctor.primary_location.city.state_name license_type = "Doctor of Osteopathic Medicine" if doctor.degree == "DO" else "Doctor of Medicine" db.session.add(License(doctor_id=doctor.id, license_type=license_type, state=state_name, expiry_date=random_date(date(2026, 10, 1), date(2031, 12, 31)), status="Active")) @@ -941,8 +1050,8 @@ def _build_doctor_children(doctors: list[Doctor], vocab: dict) -> None: db.session.add(License(doctor_id=doctor.id, license_type=license_type, state=other_state, expiry_date=random_date(date(2026, 10, 1), date(2031, 12, 31)), status="Active")) db.session.add(Education(doctor_id=doctor.id, kind="Medical School", institution=doctor.medical_school, year=doctor.graduation_year)) db.session.add(Education(doctor_id=doctor.id, kind="Residency", institution=RNG.choice(TRAINING_HOSPITALS), year=residency_year)) - if RNG.random() < 0.5: - db.session.add(Education(doctor_id=doctor.id, kind="Fellowship", institution=RNG.choice(TRAINING_HOSPITALS), year=min(residency_year + RNG.randint(1, 3), MIRROR_REFERENCE_DATE.year))) + if plan["fellowship_year"] is not None: + db.session.add(Education(doctor_id=doctor.id, kind="Fellowship", institution=RNG.choice(TRAINING_HOSPITALS), year=plan["fellowship_year"])) db.session.add(DoctorLanguage(doctor_id=doctor.id, language="English", position=1)) if RNG.random() < 0.45: db.session.add(DoctorLanguage(doctor_id=doctor.id, language=RNG.choice(LANGUAGES), position=2)) @@ -1018,6 +1127,20 @@ def _bio_html(doctor: Doctor, spec_name: str, practice: Practice, city_name: str return "\n".join(paragraphs) +def _bound_review_stars(doctors: list[Doctor]) -> None: + """Keep the mean of a doctor's visible review stars within 1.0 of the profile average: + while it drifts further, nudge the review (lowest id first) farthest from the average one + star toward it. No RNG.""" + for doctor in doctors: + if doctor.avg_rating is None or not doctor.reviews: + continue + reviews = sorted(doctor.reviews, key=lambda r: r.id) + while abs(sum(r.rating for r in reviews) / len(reviews) - doctor.avg_rating) > 1.0: + farthest = max(reviews, key=lambda r: (abs(r.rating - doctor.avg_rating), -r.id)) + farthest.rating += 1 if farthest.rating < doctor.avg_rating else -1 + db.session.flush() + + def _ensure_similar_tiers() -> None: """Every condition / procedure facet keeps at least two doctors tiered "Similar".""" for model, key in ((DoctorCondition, "condition_id"), (DoctorProcedure, "procedure_id")): @@ -1219,6 +1342,7 @@ def seed_database(force: bool = False) -> None: practices = _build_practices(vocab["cities"], used_phones) doctors = _build_doctors(vocab, hospitals, practices, used_phones) _build_doctor_children(doctors, vocab) + _bound_review_stars(doctors) _ensure_similar_tiers() _build_awards(doctors, vocab) _finish_hubs(Hospital.query.order_by(Hospital.id).all(), Practice.query.order_by(Practice.id).all()) diff --git a/sites/webmd_doctor/tasks.jsonl b/sites/webmd_doctor/tasks.jsonl index a97c6255..40a60783 100644 --- a/sites/webmd_doctor/tasks.jsonl +++ b/sites/webmd_doctor/tasks.jsonl @@ -2,17 +2,17 @@ {"web_name": "WebMD Doctor", "id": "WebMD Doctor--1", "ques": "Find Dr. Julian Zamora, a Cardiovascular Disease specialist whose primary office is in Wilmington, DE. Report the NPI number shown on the profile and the languages spoken.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Cardiologist&sids=29236"} {"web_name": "WebMD Doctor", "id": "WebMD Doctor--2", "ques": "Search for Family Medicine doctors near Newark, DE 19711 and open Dr. Ruth Thackeray's profile. Report the phone number listed for the primary office and that office's Saturday hours.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Family+Medicine"} {"web_name": "WebMD Doctor", "id": "WebMD Doctor--3", "ques": "Open the profile of Dr. Mateo Alvarado, a Neurologist in West Chester, PA. Besides the primary office, the Locations section lists one other office. Report that office's name and street address.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/neurology/pennsylvania/west-chester"} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--4", "ques": "Find Dr. Rafael Khoury, an Orthopedic Surgeon in Elkton, MD. From the Certifications, License, & Education section, report the board that certified them, the certification year, and the institution where they completed their residency.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/orthopedic-surgery/maryland/elkton"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--4", "ques": "Find Dr. Charles Villanueva, an Orthopedic Surgeon in Elkton, MD. From the Certifications, License, & Education section, report the board that certified them, the certification year, and the institution where they completed their residency.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/orthopedic-surgery/maryland/elkton"} {"web_name": "WebMD Doctor", "id": "WebMD Doctor--5", "ques": "Search for Gastroenterologists near Newark, DE 19711 and open Dr. Caroline Danforth's profile. Among the five most-treated conditions shown, exactly one is marked \"More Than Most\". Which condition is it, and which condition is listed first under \"View Top 20\"?", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Gastroenterologist"} {"web_name": "WebMD Doctor", "id": "WebMD Doctor--6", "ques": "Open the profile of Dr. Fatima Jensen, a Psychiatrist in Media, PA, and read all of their reviews. What is the date shown on the oldest review, and what star rating did that reviewer give?", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/psychiatry/pennsylvania/media"} {"web_name": "WebMD Doctor", "id": "WebMD Doctor--7", "ques": "Find Dr. Lillian Acosta, an Obstetrics & Gynecology specialist in Salem, NJ. Which of the seven Patients' Perspective criteria received the most needs-improvement votes, and what average wait time is shown on the profile?", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/obstetrics-gynecology/new-jersey/salem"} {"web_name": "WebMD Doctor", "id": "WebMD Doctor--8", "ques": "Log in as alice.j@test.com (password: TestPass123!) and open Saved Providers. Exactly one of your saved providers is a Dermatologist. Open that profile and report the institution where they completed their residency, then remove that provider from your saved list.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/"} {"web_name": "WebMD Doctor", "id": "WebMD Doctor--9", "ques": "Search for Dermatologists near Newark, DE 19711 who are female, accept new patients and accept Blue Cross Blue Shield. Among the results, open the profile of the doctor with fewer than 5 years of experience and report their medical school and the year of their board certification.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Dermatologist&sids=29244"} {"web_name": "WebMD Doctor", "id": "WebMD Doctor--10", "ques": "Find Psychiatrists near Newark, DE 19711 who accept Medicaid and have a rating of 4 stars or higher. Open the profile of the one who offers virtual visits and report the average wait time and the residency institution listed.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Psychiatrist"} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--11", "ques": "Set the distance to 10 miles from Newark, DE 19711, search for Family Medicine doctors and sort by Number of Ratings. Open the profile of the doctor with the second-highest number of ratings and report their NPI number and the hospital they are affiliated with.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Family+Medicine"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--11", "ques": "Set the distance to 10 miles from Newark, DE 19711, search for Family Medicine doctors and sort by Number of Ratings. Open the profile of the doctor with the second-highest number of ratings and report their NPI number and the institution where they completed their residency.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Family+Medicine"} {"web_name": "WebMD Doctor", "id": "WebMD Doctor--12", "ques": "From the Find Providers by Specialty menu open Cardiovascular Disease, then Pennsylvania, then West Chester. Filter to doctors rated 4 stars or higher. Open the profile of the only male doctor in that list and report his fellowship institution and the year he completed it.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/cardiovascular-disease/pennsylvania/west-chester"} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--13", "ques": "Among Dermatologists in Wilmington, DE, Dr. Gregory Greenwood and Dr. Dana Valdez both accept Blue Cross Blue Shield. Which of the two graduated from medical school earlier? Report that doctor's name and graduation year.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/dermatology/delaware/wilmington"} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--14", "ques": "Open the hospital page for Christina Creek Medical Center (Find a Facility > Hospitals > Delaware). Two of its listed physicians are Neurologists; open both profiles. Which one was board certified more recently? Report that doctor's name and the certification year.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/hospitals/delaware"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--13", "ques": "Among Dermatologists in Wilmington, DE, Dr. Gregory Greenwood and Dr. Emerson Huang both accept Blue Cross Blue Shield. Which of the two graduated from medical school earlier? Report that doctor's name and graduation year.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/dermatology/delaware/wilmington"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--14", "ques": "Open the hospital page for Christina Creek Medical Center (Find a Facility > Hospitals > Delaware). Two of its listed physicians are Psychiatrists; open both profiles. Which one was board certified more recently? Report that doctor's name and the certification year.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/hospitals/delaware"} {"web_name": "WebMD Doctor", "id": "WebMD Doctor--15", "ques": "From the header, open Award Winning Hospitals, then view the WebMD Patient's Choice recipients. Find the recipient who practices Orthopedic Surgery in Media, PA, open their profile, then open the practice page linked from their primary office. Report the practice's website address and its Saturday hours.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/choice-awards/awardrecipients?award-class=patient"} {"web_name": "WebMD Doctor", "id": "WebMD Doctor--16", "ques": "Log in with the demo account (email: bob.c@test.com, password: TestPass123!), search for Pediatricians near Newark, DE 19711, open the profile of Dr. Anita Castellano and save the provider. Then open Saved Providers and confirm Dr. Castellano appears there.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Pediatrician"} {"web_name": "WebMD Doctor", "id": "WebMD Doctor--17", "ques": "Log in as carol.d@test.com (password: TestPass123!). Open the profile of Dr. Sarah Keller, a Cardiovascular Disease specialist in Newark, DE, and request an appointment as a New Patient at the Riverfront Heart & Vascular - Wellness Center office on Mon, Sep 14 at 10:30 AM. Report the confirmation reference shown after submitting.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Cardiologist&sids=29236"} From 3c22d4a06ced83f250a8def460e4d809b75a77d0 Mon Sep 17 00:00:00 2001 From: evanz37 <89922710+evanz37@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:05:40 -0400 Subject: [PATCH 13/21] fix(webmd_doctor): audit-D nits (logout clears the session, stricter next, no __pycache__ in the image) - logout(): session.clear() before logout_user() - safe_next(): reject leading/trailing whitespace (raw and decoded), strip fragments explicitly - Dockerfile: rm -rf __pycache__ after the build-time seed run Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YKXfhRp7xMmrgixw5Ccohe --- Dockerfile | 2 +- sites/webmd_doctor/app.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index f62999d3..d93df3e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,7 +55,7 @@ RUN cd /opt/WebSyn/walmart_careers && rm -rf instance instance_seed && \ RUN test -n "$(ls -A /opt/WebSyn/webmd_doctor/static/images/avatars)" && \ test -n "$(ls -A /opt/WebSyn/webmd_doctor/static/images/posters)" RUN cd /opt/WebSyn/webmd_doctor && rm -rf instance instance_seed && \ - PYTHONHASHSEED=0 python seed_data.py && rm -rf instance + PYTHONHASHSEED=0 python seed_data.py && rm -rf instance __pycache__ COPY websyn_start.sh /opt/websyn_start.sh COPY control_server.py /opt/control_server.py diff --git a/sites/webmd_doctor/app.py b/sites/webmd_doctor/app.py index 831f7c5f..0fe86382 100644 --- a/sites/webmd_doctor/app.py +++ b/sites/webmd_doctor/app.py @@ -21,6 +21,7 @@ redirect, render_template, request, + session, url_for, ) from flask_login import ( @@ -729,7 +730,7 @@ def office_index(doctor: Doctor, location: Location) -> int: def safe_next(raw: str | None) -> str | None: """Return a same-origin relative path (with query) or ``None``.""" - if not raw or len(raw) > 2048 or any(ord(char) < 32 for char in raw): + if not raw or len(raw) > 2048 or raw != raw.strip() or any(ord(char) < 32 for char in raw): return None decoded = raw for _ in range(3): @@ -737,8 +738,9 @@ def safe_next(raw: str | None) -> str | None: if expanded == decoded: break decoded = expanded - if decoded.startswith("//") or "\\" in decoded: + if decoded != decoded.strip() or decoded.startswith("//") or "\\" in decoded: return None + decoded = decoded.split("#", 1)[0] # fragments never travel in a redirect target parsed = urlsplit(decoded) if parsed.scheme or parsed.netloc or not parsed.path.startswith("/") or parsed.path.startswith("//"): return None @@ -1853,6 +1855,7 @@ def signup(): @app.route("/logout", methods=["POST"]) @login_required def logout(): + session.clear() # drop every session key first; logout_user() then flags the remember cookie for removal logout_user() return redirect(url_for("index")) From 5657fddb2c61effdf167bba9f5eea5d7dbb38dfa Mon Sep 17 00:00:00 2001 From: evanz37 <89922710+evanz37@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:53:11 -0400 Subject: [PATCH 14/21] test(webmd_doctor): add deterministic task verifiers and verify_lib 20 verifiers (verify_0..19.py) plus verify_lib.py and ground_truth.py under sites/webmd_doctor/verify/, in the walmart_careers contract: argparse CLI, zero LLM calls, fail-closed snapshot discovery (run_dir or docker cp), pinned 28-table schema hash + seed marker + counts, catalog tables row-identical, read-only tasks require the four runtime tables unchanged, stateful tasks enforce exact row deltas (saved provider removed/added, appointment row matched by user/doctor/office/slot with the answer's reference taken from the new row, review row by rating/text/status, registration by the e-mail typed on /signup). Route gates follow the task text (results query params incl. the Newark rule and combined filters, specialty menu path, hospital and awards pages, second review page); comparison tasks gate on both profiles. ground_truth.py re-derives every target from the initial snapshot and fails closed on disagreement. verify/tests: 372 subprocess tests (genuine, shortcut, wrong answer, state mismatch, over-action, read-only write, packaging and contract negatives) plus 25 library tests; no docker, no API key. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017yDUT66ukMXuASicT7yd6H --- sites/webmd_doctor/verify/README.md | 38 + sites/webmd_doctor/verify/ground_truth.py | 321 ++++++ sites/webmd_doctor/verify/tests/_support.py | 289 ++++++ .../verify/tests/test_verify_0.py | 70 ++ .../verify/tests/test_verify_1.py | 67 ++ .../verify/tests/test_verify_10.py | 68 ++ .../verify/tests/test_verify_11.py | 68 ++ .../verify/tests/test_verify_12.py | 70 ++ .../verify/tests/test_verify_13.py | 70 ++ .../verify/tests/test_verify_14.py | 76 ++ .../verify/tests/test_verify_15.py | 70 ++ .../verify/tests/test_verify_16.py | 80 ++ .../verify/tests/test_verify_17.py | 94 ++ .../verify/tests/test_verify_18.py | 90 ++ .../verify/tests/test_verify_19.py | 92 ++ .../verify/tests/test_verify_2.py | 64 ++ .../verify/tests/test_verify_3.py | 58 ++ .../verify/tests/test_verify_4.py | 62 ++ .../verify/tests/test_verify_5.py | 63 ++ .../verify/tests/test_verify_6.py | 68 ++ .../verify/tests/test_verify_7.py | 63 ++ .../verify/tests/test_verify_8.py | 79 ++ .../verify/tests/test_verify_9.py | 74 ++ .../verify/tests/test_verify_lib.py | 203 ++++ sites/webmd_doctor/verify/verify_0.py | 63 ++ sites/webmd_doctor/verify/verify_1.py | 60 ++ sites/webmd_doctor/verify/verify_10.py | 68 ++ sites/webmd_doctor/verify/verify_11.py | 63 ++ sites/webmd_doctor/verify/verify_12.py | 72 ++ sites/webmd_doctor/verify/verify_13.py | 62 ++ sites/webmd_doctor/verify/verify_14.py | 71 ++ sites/webmd_doctor/verify/verify_15.py | 72 ++ sites/webmd_doctor/verify/verify_16.py | 75 ++ sites/webmd_doctor/verify/verify_17.py | 91 ++ sites/webmd_doctor/verify/verify_18.py | 83 ++ sites/webmd_doctor/verify/verify_19.py | 90 ++ sites/webmd_doctor/verify/verify_2.py | 64 ++ sites/webmd_doctor/verify/verify_3.py | 60 ++ sites/webmd_doctor/verify/verify_4.py | 63 ++ sites/webmd_doctor/verify/verify_5.py | 62 ++ sites/webmd_doctor/verify/verify_6.py | 64 ++ sites/webmd_doctor/verify/verify_7.py | 60 ++ sites/webmd_doctor/verify/verify_8.py | 75 ++ sites/webmd_doctor/verify/verify_9.py | 69 ++ sites/webmd_doctor/verify/verify_lib.py | 940 ++++++++++++++++++ 45 files changed, 4624 insertions(+) create mode 100644 sites/webmd_doctor/verify/README.md create mode 100644 sites/webmd_doctor/verify/ground_truth.py create mode 100644 sites/webmd_doctor/verify/tests/_support.py create mode 100644 sites/webmd_doctor/verify/tests/test_verify_0.py create mode 100644 sites/webmd_doctor/verify/tests/test_verify_1.py create mode 100644 sites/webmd_doctor/verify/tests/test_verify_10.py create mode 100644 sites/webmd_doctor/verify/tests/test_verify_11.py create mode 100644 sites/webmd_doctor/verify/tests/test_verify_12.py create mode 100644 sites/webmd_doctor/verify/tests/test_verify_13.py create mode 100644 sites/webmd_doctor/verify/tests/test_verify_14.py create mode 100644 sites/webmd_doctor/verify/tests/test_verify_15.py create mode 100644 sites/webmd_doctor/verify/tests/test_verify_16.py create mode 100644 sites/webmd_doctor/verify/tests/test_verify_17.py create mode 100644 sites/webmd_doctor/verify/tests/test_verify_18.py create mode 100644 sites/webmd_doctor/verify/tests/test_verify_19.py create mode 100644 sites/webmd_doctor/verify/tests/test_verify_2.py create mode 100644 sites/webmd_doctor/verify/tests/test_verify_3.py create mode 100644 sites/webmd_doctor/verify/tests/test_verify_4.py create mode 100644 sites/webmd_doctor/verify/tests/test_verify_5.py create mode 100644 sites/webmd_doctor/verify/tests/test_verify_6.py create mode 100644 sites/webmd_doctor/verify/tests/test_verify_7.py create mode 100644 sites/webmd_doctor/verify/tests/test_verify_8.py create mode 100644 sites/webmd_doctor/verify/tests/test_verify_9.py create mode 100644 sites/webmd_doctor/verify/tests/test_verify_lib.py create mode 100644 sites/webmd_doctor/verify/verify_0.py create mode 100644 sites/webmd_doctor/verify/verify_1.py create mode 100644 sites/webmd_doctor/verify/verify_10.py create mode 100644 sites/webmd_doctor/verify/verify_11.py create mode 100644 sites/webmd_doctor/verify/verify_12.py create mode 100644 sites/webmd_doctor/verify/verify_13.py create mode 100644 sites/webmd_doctor/verify/verify_14.py create mode 100644 sites/webmd_doctor/verify/verify_15.py create mode 100644 sites/webmd_doctor/verify/verify_16.py create mode 100644 sites/webmd_doctor/verify/verify_17.py create mode 100644 sites/webmd_doctor/verify/verify_18.py create mode 100644 sites/webmd_doctor/verify/verify_19.py create mode 100644 sites/webmd_doctor/verify/verify_2.py create mode 100644 sites/webmd_doctor/verify/verify_3.py create mode 100644 sites/webmd_doctor/verify/verify_4.py create mode 100644 sites/webmd_doctor/verify/verify_5.py create mode 100644 sites/webmd_doctor/verify/verify_6.py create mode 100644 sites/webmd_doctor/verify/verify_7.py create mode 100644 sites/webmd_doctor/verify/verify_8.py create mode 100644 sites/webmd_doctor/verify/verify_9.py create mode 100644 sites/webmd_doctor/verify/verify_lib.py diff --git a/sites/webmd_doctor/verify/README.md b/sites/webmd_doctor/verify/README.md new file mode 100644 index 00000000..a909f30f --- /dev/null +++ b/sites/webmd_doctor/verify/README.md @@ -0,0 +1,38 @@ +# WebMD Doctor deterministic grading contract + +Each row in `sites/webmd_doctor/tasks.jsonl` points to `verify_0.py` through `verify_19.py`. The wrappers use `verify_lib.py` for package, URL, answer and state validation and `ground_truth.py` to re-derive every target from the supplied initial SQLite snapshot. No verifier calls an LLM; a verdict never depends on a key or a model. + +## Inputs + +```bash +python sites/webmd_doctor/verify/verify_0.py \ + --run_dir /absolute/path/to/run \ + --initial_db /absolute/path/to/initial.db \ + --after_db /absolute/path/to/after.db +``` + +If explicit snapshots are omitted, the verifier checks `/initial.db` and `/after.db`, then falls back to `docker cp` from `$WH_CONTAINER` (default `wh-review`): `instance_seed/webmd_doctor.db` is the initial state and `instance/webmd_doctor.db` the after state. Missing or invalid inputs fail closed (`infra_error: true`, exit 1). Output is JSON with `task_id`, `pass`, `reason` (the first failing check) and `evidence`; exit code 0 means PASS and 1 means FAIL. `agent_demo/eval_judge.py --run_dir --verifier True` is the normal entry point; a `--no_llm` flag is accepted for parity and ignored. + +## Package validation + +Every run must provide the exact task ID, a nonempty final answer, `terminated: true` with `termination_reason: agent_done`, at least one recorded step, HTTP URLs on the same loopback origin and port as `start_url`, and both referenced screenshots for every step decoding as nonempty PNGs. A `max_steps` run has no final answer and fails on the first check. + +## Snapshot validation + +Both snapshots must have the exact 28-table WebMD Doctor schema (hash pinned), `seed_metadata.version = webmd-doctor-v1`, and the frozen seed counts (226 doctors, 348 locations, 12 hospitals, 30 practices, 10 specialties, 4 users, 4 saved providers, 1 appointment request, 1 pending user review). The 24 catalog tables must be row-identical before and after. `ground_truth.py` then re-derives the task's target from the initial snapshot the way the task text selects it (specialty + city, combined filters within the search radius, the saved list, a hospital roster, an award class) and fails closed if it disagrees with the constants hardcoded in the verifier. + +The four runtime tables are `users`, `saved_providers`, `appointment_requests` and `user_reviews`. Read-only tasks (0-7, 9-15) require all four to be row-identical, so an incidental save or review fails. Stateful verifiers (8, 16, 17, 18, 19) enforce the exact row delta on the touched table and identity on the others: removing the wrong saved provider, a second booking, a review on a same-surname doctor, a registration with a different e-mail than the one typed on the Sign Up page, or a save under a demo account all fail on a named check. + +## Gates and answer matchers + +Profile visits match `/doctor/-overview` and its three tab aliases; booking, save and review endpoints are not profile visits. Comparison tasks (13, 14) require both detail pages. Route gates apply only where the task text mandates the route: a `/results` visit whose query carries the specialty (as `q` text or `sids`), the "near Newark, DE 19711" rule (`loc` absent, `19711`, or Newark, DE; any other city fails), and, for tasks 9-11, every requested filter on one URL. Task 12 requires the specialty menu path in order plus the 4-stars-and-up filter on the city page; 14 requires the Delaware hospitals list and the hospital page before the profiles; 15 the awards page, the Patient's Choice recipients list, the profile and the practice page in order; 6 the second review page; 16 the Saved Providers page after the profile; 18 ends on the profile. + +Answer matchers are negation-aware whole-token matches: institution and office names (punctuation, dash style and `&`/`and` ignored), years as standalone four-digit tokens, NPIs as one 10-digit token, phones by digit comparison, opening windows by both clock endpoints (`8 am`, `8:00 AM`, `08:00`), wait times in minutes with clock times masked, star ratings tied to a star/rating token, review dates in six formats, first + last name for comparison winners, the booking reference matched against the new database row (never a literal, and no other reference may appear), and the practice website with its domain ending. + +## Tests + +```bash +python -m pytest sites/webmd_doctor/verify/tests -q +``` + +The fixtures copy the frozen seed and rewrite only the four runtime tables, then invoke every verifier as a subprocess with hand-written trajectories in the `agent_demo/agent.py` shape. Every task covers the genuine run, run-dir snapshot discovery, the no-op run, a wrong task id, an unterminated run, a mixed-origin run, a corrupt screenshot, schema and catalog tampering, a wrong seed marker, a re-targeted seed, the knowledge shortcut, wrong answers per fact, and (read-only) an incidental write or (stateful) missing, duplicated, misattributed and collateral rows. `test_verify_lib.py` covers each matcher's accepted variants and rejections, the Newark rule, ordered workflows and the snapshot contract. No docker and no API key are needed. diff --git a/sites/webmd_doctor/verify/ground_truth.py b/sites/webmd_doctor/verify/ground_truth.py new file mode 100644 index 00000000..bb7e4640 --- /dev/null +++ b/sites/webmd_doctor/verify/ground_truth.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +"""Re-derive every WebMD Doctor task target from a supplied initial SQLite snapshot. + +``task_ground_truth(db, n)`` selects the target exactly the way the task text does +(specialty + city, filters, saved list, hospital roster, award class, ...) using +plain SQL and the same haversine formula as the site, then asserts the result is +the target hardcoded in ``verify_N.py``. Any disagreement raises ``ValueError`` +and the verifier fails closed (``snapshot_contract_invalid``): a re-frozen seed +can never silently turn a verifier into a false PASS or a false FAIL. +""" +from __future__ import annotations + +import math +import sqlite3 +from pathlib import Path +from typing import Any, Callable + +NEWARK = (39.6837, -75.7497) +SPECIALTY = { + "dermatology": 1, "cardiovascular-disease": 2, "family-medicine": 3, "neurology": 4, + "orthopedic-surgery": 5, "gastroenterology": 6, "psychiatry": 7, "obstetrics-gynecology": 8, + "pediatrics": 9, "internal-medicine": 10, +} +CITY = {"newark": 1, "bear": 2, "wilmington": 3, "elkton": 4, "salem": 5, "west-chester": 6, "media": 7, "baltimore": 8} +BCBS_INSURER_ID = 4 + +# The slug(s) each verifier hardcodes. The derivation below must reproduce them. +EXPECTED_SLUGS: dict[int, str | tuple[str, str]] = { + 0: "jonah-dimitriou-c4ce067c", + 1: "julian-zamora-d412b77d", + 2: "ruth-thackeray-45234b97", + 3: "mateo-alvarado-f727bd61", + 4: "charles-villanueva-13e969b2", + 5: "caroline-danforth-7093653d", + 6: "fatima-jensen-e5a26d53", + 7: "lillian-acosta-4a89e15e", + 8: "adrian-navarro-b338ac81", + 9: "nicole-dubois-243c6e66", + 10: "veronica-nwachukwu-71051d38", + 11: "sean-blackwood-45e84c50", + 12: "joseph-iyer-29b88273", + 13: ("emerson-huang-f6afead5", "gregory-greenwood-34670192"), + 14: ("arjun-bouchard-f3c85053", "colin-ellery-640b0a4a"), + 15: "linda-merriweather-378753c8", + 16: "anita-castellano-14da1f29", + 17: "sarah-keller-f85bed81", + 18: "tariq-huang-c8120504", + 19: "monica-carrington-62f5d8a2", +} + + +def haversine_miles(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + radius = 3958.7613 + p1, p2 = math.radians(lat1), math.radians(lat2) + dp = math.radians(lat2 - lat1) + dl = math.radians(lon2 - lon1) + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 + return 2 * radius * math.asin(math.sqrt(a)) + + +def _connect(db_path: str | Path) -> sqlite3.Connection: + connection = sqlite3.connect(str(db_path)) + connection.row_factory = sqlite3.Row + return connection + + +def _one(rows: list[dict[str, Any]], description: str) -> dict[str, Any]: + if len(rows) != 1: + raise ValueError(f"{description} must be unique; observed {len(rows)} rows: {[row.get('slug') for row in rows]}") + return rows[0] + + +def _extreme(rows: list[dict[str, Any]], key: Callable[[dict[str, Any]], Any], maximum: bool, description: str) -> dict[str, Any]: + if not rows: + raise ValueError(f"{description} has no candidates") + value = (max if maximum else min)(key(row) for row in rows) + return _one([row for row in rows if key(row) == value], description) + + +def _doctors(connection: sqlite3.Connection) -> list[dict[str, Any]]: + """Every doctor joined with the primary location (the row the search and city pages use).""" + rows = connection.execute( + "SELECT d.*, l.id AS location_id, l.city_id, l.lat AS loc_lat, l.lon AS loc_lon, l.medicaid AS loc_medicaid, " + "l.practice_id, l.phone AS loc_phone, l.sat_open, l.sat_close " + "FROM doctors d JOIN locations l ON l.doctor_id = d.id AND l.is_primary = 1 ORDER BY d.id" + ).fetchall() + output = [dict(row) for row in rows] + for row in output: + row["distance"] = haversine_miles(*NEWARK, row["loc_lat"], row["loc_lon"]) + return output + + +def _has_specialty(row: dict[str, Any], specialty_id: int) -> bool: + return row["primary_specialty_id"] == specialty_id or row["secondary_specialty_id"] == specialty_id + + +def _insurer_ids(connection: sqlite3.Connection, doctor_id: int) -> set[int]: + return { + int(r[0]) for r in connection.execute( + "SELECT p.insurer_id FROM doctor_insurances di JOIN insurance_plans p ON p.id = di.plan_id WHERE di.doctor_id = ?", + (doctor_id,), + ) + } + + +def _education(connection: sqlite3.Connection, doctor_id: int, kind: str) -> list[dict[str, Any]]: + return [dict(r) for r in connection.execute("SELECT * FROM education WHERE doctor_id = ? AND kind = ? ORDER BY id", (doctor_id, kind))] + + +def _certifications(connection: sqlite3.Connection, doctor_id: int) -> list[dict[str, Any]]: + return [dict(r) for r in connection.execute("SELECT * FROM certifications WHERE doctor_id = ? ORDER BY id", (doctor_id,))] + + +def _locations(connection: sqlite3.Connection, doctor_id: int) -> list[dict[str, Any]]: + return [dict(r) for r in connection.execute("SELECT * FROM locations WHERE doctor_id = ? ORDER BY is_primary DESC, id", (doctor_id,))] + + +def _user_id(connection: sqlite3.Connection, email: str) -> int: + row = connection.execute("SELECT id FROM users WHERE lower(email)=lower(?)", (email,)).fetchone() + if row is None: + raise ValueError(f"missing benchmark user {email}") + return int(row[0]) + + +def _named(doctors: list[dict[str, Any]], first: str, last: str, specialty: str, city: str) -> dict[str, Any]: + rows = [ + row for row in doctors + if row["first_name"] == first and row["last_name"] == last + and row["primary_specialty_id"] == SPECIALTY[specialty] and row["city_id"] == CITY[city] + ] + return _one(rows, f"{first} {last} ({specialty}, {city})") + + +def _expect(slugs: Any, task_number: int) -> None: + expected = EXPECTED_SLUGS[task_number] + observed = tuple(slugs) if isinstance(slugs, (list, tuple)) else slugs + if isinstance(expected, tuple): + if set(observed) != set(expected): + raise ValueError(f"task {task_number} targets differ: expected={expected}, derived={observed}") + elif observed != expected: + raise ValueError(f"task {task_number} target differs: expected={expected!r}, derived={observed!r}") + + +def task_ground_truth(db_path: str | Path, task_number: int) -> dict[str, Any]: + connection = _connect(db_path) + try: + return _derive(connection, task_number) + finally: + connection.close() + + +def _derive(c: sqlite3.Connection, n: int) -> dict[str, Any]: + doctors = _doctors(c) + fact: dict[str, Any] = {"task": n} + + if n == 0: + t = _named(doctors, "Jonah", "Dimitriou", "dermatology", "newark") + fact.update(target=t, school=t["medical_school"], graduation_year=t["graduation_year"]) + elif n == 1: + t = _named(doctors, "Julian", "Zamora", "cardiovascular-disease", "wilmington") + languages = [r[0] for r in c.execute("SELECT language FROM doctor_languages WHERE doctor_id=? ORDER BY position", (t["id"],))] + fact.update(target=t, npi=t["npi"], languages=languages) + elif n == 2: + t = _named(doctors, "Ruth", "Thackeray", "family-medicine", "newark") + if not t["sat_open"]: + raise ValueError("task 2 primary office is closed on Saturday") + fact.update(target=t, phone=t["loc_phone"], saturday=(t["sat_open"], t["sat_close"])) + elif n == 3: + t = _named(doctors, "Mateo", "Alvarado", "neurology", "west-chester") + locations = _locations(c, t["id"]) + if len(locations) != 2: + raise ValueError(f"task 3 needs exactly two offices; observed {len(locations)}") + fact.update(target=t, other_office=locations[1]) + elif n == 4: + t = _named(doctors, "Charles", "Villanueva", "orthopedic-surgery", "elkton") + certs = _certifications(c, t["id"]) + residency = _education(c, t["id"], "Residency") + if len(certs) != 1 or len(residency) != 1: + raise ValueError("task 4 needs exactly one certification and one residency row") + fact.update(target=t, board=certs[0]["issuer"], cert_year=certs[0]["year"], residency=residency[0]["institution"]) + elif n == 5: + t = _named(doctors, "Caroline", "Danforth", "gastroenterology", "newark") + rows = [dict(r) for r in c.execute( + "SELECT dc.tier, cd.name FROM doctor_conditions dc JOIN conditions cd ON cd.id = dc.condition_id " + "WHERE dc.doctor_id = ? ORDER BY dc.position", (t["id"],))] + top5 = [r for r in rows[:5] if r["tier"] == "More Than Most"] + if len(top5) != 1 or len(rows) < 6: + raise ValueError("task 5 needs exactly one More Than Most condition among the first five and a sixth row") + fact.update(target=t, more_than_most=top5[0]["name"], first_top20=rows[5]["name"]) + elif n == 6: + t = _named(doctors, "Fatima", "Jensen", "psychiatry", "media") + reviews = [dict(r) for r in c.execute("SELECT rating, review_date FROM reviews WHERE doctor_id=? ORDER BY review_date, id", (t["id"],))] + if len(reviews) < 6: + raise ValueError("task 6 needs a second review page (more than five reviews)") + if len(reviews) > 1 and reviews[0]["review_date"] == reviews[1]["review_date"]: + raise ValueError("task 6 oldest review date is tied") + fact.update(target=t, oldest_date=reviews[0]["review_date"], oldest_rating=reviews[0]["rating"], review_count=len(reviews)) + elif n == 7: + t = _named(doctors, "Lillian", "Acosta", "obstetrics-gynecology", "salem") + rows = [dict(r) for r in c.execute("SELECT criterion, needs_improvement FROM doctor_perspectives WHERE doctor_id=?", (t["id"],))] + worst = _extreme(rows, lambda r: r["needs_improvement"], True, "task 7 most needs-improvement votes") + fact.update(target=t, criterion=worst["criterion"], wait_minutes=t["avg_wait_minutes"]) + elif n == 8: + alice = _user_id(c, "alice.j@test.com") + saved = {int(r[0]) for r in c.execute("SELECT doctor_id FROM saved_providers WHERE user_id=?", (alice,))} + derms = [row for row in doctors if row["id"] in saved and row["primary_specialty_id"] == SPECIALTY["dermatology"]] + t = _one(derms, "task 8 alice's saved Dermatologist") + residency = _education(c, t["id"], "Residency") + fact.update(target=t, user_id=alice, residency=_one(residency, "task 8 residency")["institution"]) + elif n == 9: + rows = [ + row for row in doctors + if _has_specialty(row, SPECIALTY["dermatology"]) and row["gender"] == "f" and row["accepting_new_patients"] + and row["distance"] <= 40 and BCBS_INSURER_ID in _insurer_ids(c, row["id"]) + ] + if len(rows) < 6: + raise ValueError(f"task 9 filtered set too small: {len(rows)}") + t = _one([row for row in rows if row["years_experience"] < 5], "task 9 under-5-years candidate") + certs = _certifications(c, t["id"]) + fact.update(target=t, candidates=rows, school=t["medical_school"], cert_year=_one(certs, "task 9 certification")["year"]) + elif n == 10: + rows = [ + row for row in doctors + if _has_specialty(row, SPECIALTY["psychiatry"]) and row["loc_medicaid"] and row["avg_rating"] is not None + and row["avg_rating"] >= 4 and row["distance"] <= 40 + ] + t = _one([row for row in rows if row["virtual_visit"]], "task 10 virtual-visit candidate") + residency = _education(c, t["id"], "Residency") + fact.update(target=t, candidates=rows, wait_minutes=t["avg_wait_minutes"], residency=_one(residency, "task 10 residency")["institution"]) + elif n == 11: + rows = [row for row in doctors if _has_specialty(row, SPECIALTY["family-medicine"]) and row["distance"] <= 10] + rows.sort(key=lambda row: (-row["ratings_count"], row["id"])) + if len(rows) < 3 or len({row["ratings_count"] for row in rows[:3]}) != 3: + raise ValueError("task 11 needs three distinct ratings counts at the top of the 10-mile list") + t = rows[1] + residency = _education(c, t["id"], "Residency") + fact.update(target=t, ranked=rows, npi=t["npi"], residency=_one(residency, "task 11 residency")["institution"]) + elif n == 12: + rows = [ + row for row in doctors + if _has_specialty(row, SPECIALTY["cardiovascular-disease"]) and row["city_id"] == CITY["west-chester"] + and row["avg_rating"] is not None and row["avg_rating"] >= 4 + ] + t = _one([row for row in rows if row["gender"] == "m"], "task 12 only male candidate") + fellowship = _education(c, t["id"], "Fellowship") + fact.update(target=t, candidates=rows, fellowship=_one(fellowship, "task 12 fellowship")) + elif n == 13: + a = _named(doctors, "Gregory", "Greenwood", "dermatology", "wilmington") + b = _named(doctors, "Emerson", "Huang", "dermatology", "wilmington") + for row in (a, b): + if BCBS_INSURER_ID not in _insurer_ids(c, row["id"]): + raise ValueError(f"task 13 candidate {row['slug']} does not accept the named insurer") + earlier = _extreme([a, b], lambda row: row["graduation_year"], False, "task 13 earlier graduate") + fact.update(targets=(a, b), earlier=earlier, graduation_year=earlier["graduation_year"]) + elif n == 14: + hospital = c.execute("SELECT id, slug, name FROM hospitals WHERE slug='christina-creek-medical-center'").fetchone() + if hospital is None: + raise ValueError("task 14 hospital is missing") + rows = [row for row in doctors if row["hospital_id"] == hospital["id"] and row["primary_specialty_id"] == SPECIALTY["psychiatry"]] + if len(rows) != 2: + raise ValueError(f"task 14 needs exactly two Psychiatrists at the hospital; observed {len(rows)}") + for row in rows: + row["cert_year"] = _one(_certifications(c, row["id"]), f"task 14 certification {row['slug']}")["year"] + recent = _extreme(rows, lambda row: row["cert_year"], True, "task 14 more recent certification") + fact.update(targets=tuple(rows), hospital=dict(hospital), more_recent=recent, cert_year=recent["cert_year"]) + elif n == 15: + winners = {int(r[0]) for r in c.execute("SELECT doctor_id FROM awards WHERE award_class='Patient'")} + rows = [row for row in doctors if row["id"] in winners and row["primary_specialty_id"] == SPECIALTY["orthopedic-surgery"] and row["city_id"] == CITY["media"]] + t = _one(rows, "task 15 Patient's Choice orthopedic surgeon in Media") + practice = c.execute("SELECT * FROM practices WHERE id=?", (t["practice_id"],)).fetchone() + if practice is None or not practice["sat_open"]: + raise ValueError("task 15 practice is missing or closed on Saturday") + fact.update(target=t, practice=dict(practice), website=practice["website"], saturday=(practice["sat_open"], practice["sat_close"])) + elif n == 16: + t = _named(doctors, "Anita", "Castellano", "pediatrics", "newark") + bob = _user_id(c, "bob.c@test.com") + if c.execute("SELECT 1 FROM saved_providers WHERE user_id=? AND doctor_id=?", (bob, t["id"])).fetchone(): + raise ValueError("task 16 target is already saved by bob in the initial snapshot") + fact.update(target=t, user_id=bob) + elif n == 17: + t = _named(doctors, "Sarah", "Keller", "cardiovascular-disease", "newark") + if t["profile_type"] != "Enhanced": + raise ValueError("task 17 target must be Enhanced (bookable)") + offices = [row for row in _locations(c, t["id"]) if row["name"] == "Riverfront Heart & Vascular - Wellness Center"] + office = _one(offices, "task 17 named office") + carol = _user_id(c, "carol.d@test.com") + if c.execute("SELECT 1 FROM appointment_requests WHERE user_id=? AND doctor_id=?", (carol, t["id"])).fetchone(): + raise ValueError("task 17 initial snapshot already has a carol/keller request") + fact.update(target=t, user_id=carol, location_id=office["id"]) + elif n == 18: + t = _named(doctors, "Tariq", "Huang", "dermatology", "elkton") + david = _user_id(c, "david.k@test.com") + if c.execute("SELECT 1 FROM user_reviews WHERE user_id=? AND doctor_id=?", (david, t["id"])).fetchone(): + raise ValueError("task 18 initial snapshot already has a david review for the target") + fact.update(target=t, user_id=david) + elif n == 19: + rows = [row for row in doctors if _has_specialty(row, SPECIALTY["neurology"]) and row["virtual_visit"] and row["distance"] <= 40] + t = _one([row for row in rows if row["first_name"] == "Monica" and row["last_name"] == "Carrington"], "task 19 Monica Carrington") + if len(rows) < 6: + raise ValueError(f"task 19 filtered set too small: {len(rows)}") + fact.update(target=t, candidates=rows, npi=t["npi"]) + else: + raise ValueError(f"unsupported WebMD Doctor task {n}") + + if "targets" in fact: + _expect([row["slug"] for row in fact["targets"]], n) + else: + _expect(fact["target"]["slug"], n) + return fact + + +def all_ground_truth(db_path: str | Path) -> dict[int, dict[str, Any]]: + return {number: task_ground_truth(db_path, number) for number in range(20)} + + +if __name__ == "__main__": # pragma: no cover - manual inspection + import json + import sys + + facts = all_ground_truth(sys.argv[1]) + print(json.dumps({k: {kk: vv for kk, vv in v.items() if kk not in ("candidates", "ranked")} for k, v in facts.items()}, indent=1, default=str)) diff --git a/sites/webmd_doctor/verify/tests/_support.py b/sites/webmd_doctor/verify/tests/_support.py new file mode 100644 index 00000000..864263c6 --- /dev/null +++ b/sites/webmd_doctor/verify/tests/_support.py @@ -0,0 +1,289 @@ +"""Shared fixtures for the webmd_doctor verifier tests. + +Synthetic snapshots are copies of the frozen seed (``instance_seed/webmd_doctor.db``) +with the four runtime tables rewritten from a small in-memory ``State``; hand-written +trajectories follow the agent_demo/agent.py shape. No docker, no LLM. +""" +from __future__ import annotations + +import base64 +import copy +import json +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from typing import Any + +VERIFY_DIR = Path(__file__).resolve().parents[1] +SITE_DIR = VERIFY_DIR.parent +SEED_DB = SITE_DIR / "instance_seed" / "webmd_doctor.db" +BASE = "http://localhost:41024" +PASSWORD = "TestPass123!" +STAMP = "2026-08-01 00:00:00.000000" +FIXTURE_HASH = "scrypt:32768:8:1$fixture$invalid" + +if not SEED_DB.exists(): # pragma: no cover - environment guard + raise unittest.SkipTest(f"frozen seed missing: {SEED_DB} (run ./scripts/fetch_assets.sh webmd_doctor)") + +# Same ids / e-mails / relations as the frozen seed (all synthetic). +SEED_USERS = [ + dict(id=1, email="alice.j@test.com", display_name="Alice Johnson", dob="1988-04-12"), + dict(id=2, email="bob.c@test.com", display_name="Bob Chen", dob="1979-11-03"), + dict(id=3, email="carol.d@test.com", display_name="Carol Davis", dob="1993-07-21"), + dict(id=4, email="david.k@test.com", display_name="David Kim", dob="1984-02-09"), +] +SEED_SAVED = [(1, 1, 5), (2, 1, 47), (3, 1, 64), (4, 2, 23)] +SEED_APPOINTMENTS = [ + dict(id=1, user_id=1, doctor_id=106, location_id=166, patient_type="Returning Patient", + slot_date="2026-09-11", slot_time="9:30 AM", reference="WMD-JASDV25V"), +] +SEED_REVIEWS = [ + dict(id=1, user_id=1, doctor_id=47, rating=4, c1=1, c2=1, c3=0, c4=1, c5=1, c6=1, c7=0, + text="Thorough annual visit and clear answers about my lab results; scheduling the follow-up took two calls.", + status="Pending review"), +] + + +class State: + """Mutable copy of the seeded users / saved_providers / appointment_requests / user_reviews.""" + + def __init__(self) -> None: + self.users = copy.deepcopy(SEED_USERS) + self.saved = list(SEED_SAVED) + self.appointments = copy.deepcopy(SEED_APPOINTMENTS) + self.reviews = copy.deepcopy(SEED_REVIEWS) + self.extra_sql: list[str] = [] + + # -- mutators ----------------------------------------------------------- + def add_user(self, email: str) -> int: + new_id = max(user["id"] for user in self.users) + 1 + self.users.append(dict(id=new_id, email=email, display_name=email.split("@")[0], dob=None)) + return new_id + + def add_saved(self, user_id: int, doctor_id: int) -> int: + new_id = max([row[0] for row in self.saved] + [0]) + 1 + self.saved.append((new_id, user_id, doctor_id)) + return new_id + + def remove_saved(self, user_id: int, doctor_id: int) -> None: + before = len(self.saved) + self.saved = [row for row in self.saved if not (row[1] == user_id and row[2] == doctor_id)] + assert len(self.saved) == before - 1, f"no saved row {user_id}/{doctor_id}" + + def add_appointment(self, user_id: int, doctor_id: int, location_id: int, patient_type: str = "New Patient", + slot_date: str = "2026-09-14", slot_time: str = "10:30 AM", reference: str = "WMD-AB2CD3EF") -> str: + new_id = max(row["id"] for row in self.appointments) + 1 + self.appointments.append(dict(id=new_id, user_id=user_id, doctor_id=doctor_id, location_id=location_id, + patient_type=patient_type, slot_date=slot_date, slot_time=slot_time, reference=reference)) + return reference + + def add_review(self, user_id: int, doctor_id: int, rating: int, text: str, status: str = "Pending review") -> int: + new_id = max(row["id"] for row in self.reviews) + 1 + self.reviews.append(dict(id=new_id, user_id=user_id, doctor_id=doctor_id, rating=rating, c1=1, c2=1, c3=1, + c4=1, c5=1, c6=1, c7=1, text=text, status=status)) + return new_id + + # -- persistence -------------------------------------------------------- + def write(self, path: Path) -> Path: + shutil.copy2(SEED_DB, path) + connection = sqlite3.connect(path) + try: + for table in ("saved_providers", "appointment_requests", "user_reviews", "users"): + connection.execute(f"DELETE FROM {table}") + connection.executemany( + "INSERT INTO users(id, email, password_hash, dob, display_name, created_at) VALUES (:id, :email, :hash, :dob, :display_name, :stamp)", + [{**user, "hash": FIXTURE_HASH, "stamp": STAMP} for user in self.users], + ) + connection.executemany( + f"INSERT INTO saved_providers(id, user_id, doctor_id, saved_at) VALUES (?, ?, ?, '{STAMP}')", + self.saved, + ) + connection.executemany( + "INSERT INTO appointment_requests(id, user_id, doctor_id, location_id, patient_type, slot_date, slot_time, reference, created_at) " + f"VALUES (:id, :user_id, :doctor_id, :location_id, :patient_type, :slot_date, :slot_time, :reference, '{STAMP}')", + self.appointments, + ) + connection.executemany( + "INSERT INTO user_reviews(id, user_id, doctor_id, rating, c1, c2, c3, c4, c5, c6, c7, text, status, created_at) " + f"VALUES (:id, :user_id, :doctor_id, :rating, :c1, :c2, :c3, :c4, :c5, :c6, :c7, :text, :status, '{STAMP}')", + self.reviews, + ) + for statement in self.extra_sql: + connection.execute(statement) + connection.commit() + finally: + connection.close() + return path + + +def step(path: str, action: str = "click", text: str | None = None) -> dict[str, Any]: + """One trajectory step in the agent.py shape; ``path`` is relative to BASE.""" + params: dict[str, Any] = {"text": text} if text is not None else {} + url = path if path.startswith("http") else f"{BASE}{path}" + return {"url": url, "action": action, "params": params} + + +def profile(slug: str) -> str: + return f"/doctor/{slug}-overview" + + +def login_steps(email: str) -> list[dict[str, Any]]: + return [step("/login", "input", email), step("/login", "input", PASSWORD), step("/login", "click")] + + +def signup_steps(email: str) -> list[dict[str, Any]]: + return [step("/login"), step("/signup", "input", email), step("/signup", "input", "DrivePass9!"), step("/signup", "click")] + + +def only_paths(steps: list[dict[str, Any]], *allowed: str) -> list[dict[str, Any]]: + """Keep the steps whose URL path is one of ``allowed`` (shortcut trajectories).""" + from urllib.parse import urlparse + + def path_of(item: dict[str, Any]) -> str: + return urlparse(item["url"]).path.rstrip("/") or "/" + + return [item for item in steps if path_of(item) in allowed] + + +def write_run(run_dir: Path, task_id: str, steps: list[dict[str, Any]], answer: str) -> None: + run_dir.mkdir(parents=True, exist_ok=True) + shots = run_dir / "screenshots" + shots.mkdir(exist_ok=True) + png = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=") + numbered = [] + for index, item in enumerate(steps): + before = f"step_{index:03d}.png" + after = f"step_{index + 1:03d}.png" + (shots / before).write_bytes(png) + (shots / after).write_bytes(png) + numbered.append({"step": index, **item, "screenshot_before": before, "screenshot_after": after}) + trajectory = { + "task": "synthetic", "task_id": task_id, "start_url": f"{BASE}/", "model": "unit-test", + "max_steps": 30, "steps": numbered, "terminated": bool(answer), + "termination_reason": "agent_done" if answer else "max_steps", + "final_answer": answer if answer else None, + } + (run_dir / "trajectory.json").write_text(json.dumps(trajectory, indent=2), encoding="utf-8") + + +class VerifierTestCase(unittest.TestCase): + """Base class: ``self.N`` selects verify_N.py; subclasses define GENUINE_STEPS / ANSWER / genuine_after.""" + + N = -1 + GENUINE_STEPS: list[dict[str, Any]] = [] + ANSWER = "" + + @property + def task_id(self) -> str: + return f"WebMD Doctor--{self.N}" + + def genuine_after(self) -> State: + return State() + + def verdict( + self, + steps: list[dict[str, Any]], + answer: str, + initial: State | None = None, + after: State | None = None, + task_id: str | None = None, + snapshots_in_run_dir: bool = False, + trajectory_updates: dict[str, Any] | None = None, + corrupt_screenshot: bool = False, + ) -> dict[str, Any]: + initial = initial or State() + after = after or State() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + run_dir = root / "run" + write_run(run_dir, task_id or self.task_id, steps, answer) + if trajectory_updates: + trajectory_path = run_dir / "trajectory.json" + trajectory = json.loads(trajectory_path.read_text()) + trajectory.update(trajectory_updates) + trajectory_path.write_text(json.dumps(trajectory, indent=2)) + if corrupt_screenshot: + first = next((run_dir / "screenshots").glob("*.png")) + first.write_bytes(b"not a png") + if snapshots_in_run_dir: + initial.write(run_dir / "initial.db") + after.write(run_dir / "after.db") + command = [sys.executable, str(VERIFY_DIR / f"verify_{self.N}.py"), "--run_dir", str(run_dir)] + else: + command = [ + sys.executable, str(VERIFY_DIR / f"verify_{self.N}.py"), "--run_dir", str(run_dir), + "--initial_db", str(initial.write(root / "initial.db")), + "--after_db", str(after.write(root / "after.db")), + ] + result = subprocess.run(command, capture_output=True, text=True) + self.assertTrue(result.stdout.strip(), f"verifier printed nothing; stderr={result.stderr}") + verdict = json.loads(result.stdout) + verdict["returncode"] = result.returncode + return verdict + + def assertPasses(self, verdict: dict[str, Any]) -> None: + self.assertTrue(verdict["pass"], verdict["evidence"]) + self.assertEqual(verdict["returncode"], 0) + self.assertEqual(verdict["reason"], "all checks passed") + + def assertFailsOn(self, verdict: dict[str, Any], reason: str) -> None: + self.assertFalse(verdict["pass"], verdict["evidence"]) + self.assertEqual(verdict["returncode"], 1) + self.assertEqual(verdict["reason"], reason, verdict["evidence"]) + + +class SharedVerifierTests: + """Mixin (not a TestCase, so it is never collected on its own): negatives every verifier rejects.""" + + def test_genuine_run_passes(self) -> None: + self.assertPasses(self.verdict(self.GENUINE_STEPS, self.ANSWER, after=self.genuine_after())) + + def test_run_dir_snapshots_are_discovered(self) -> None: + self.assertPasses(self.verdict(self.GENUINE_STEPS, self.ANSWER, after=self.genuine_after(), snapshots_in_run_dir=True)) + + def test_noop_run_fails_on_empty_answer(self) -> None: + self.assertFailsOn(self.verdict([step("/")], ""), "final_answer_nonempty") + + def test_other_task_trajectory_fails(self) -> None: + verdict = self.verdict(self.GENUINE_STEPS, self.ANSWER, after=self.genuine_after(), task_id="WebMD Doctor--99") + self.assertFailsOn(verdict, "trajectory_task_matches") + + def test_unterminated_run_fails(self) -> None: + verdict = self.verdict(self.GENUINE_STEPS, self.ANSWER, after=self.genuine_after(), trajectory_updates={"terminated": False}) + self.assertFailsOn(verdict, "trajectory_completed") + + def test_mixed_origin_run_fails(self) -> None: + verdict = self.verdict(self.GENUINE_STEPS, self.ANSWER, after=self.genuine_after(), + trajectory_updates={"start_url": "http://127.0.0.1:41024/"}) + self.assertFailsOn(verdict, "all_urls_match_local_origin") + + def test_corrupt_screenshot_fails(self) -> None: + self.assertFailsOn(self.verdict(self.GENUINE_STEPS, self.ANSWER, after=self.genuine_after(), corrupt_screenshot=True), "screenshots_decode") + + def test_schema_change_fails_closed(self) -> None: + after = self.genuine_after() + after.extra_sql.append("CREATE TABLE injected(id INTEGER PRIMARY KEY)") + self.assertFailsOn(self.verdict(self.GENUINE_STEPS, self.ANSWER, after=after), "snapshot_contract_invalid") + + def test_catalog_change_fails_closed(self) -> None: + after = self.genuine_after() + after.extra_sql.append("UPDATE doctors SET medical_school='tampered' WHERE id=1") + self.assertFailsOn(self.verdict(self.GENUINE_STEPS, self.ANSWER, after=after), "snapshot_contract_invalid") + + def test_wrong_seed_marker_fails_closed(self) -> None: + initial = State() + initial.extra_sql.append("UPDATE seed_metadata SET value='wrong' WHERE key='version'") + self.assertFailsOn(self.verdict(self.GENUINE_STEPS, self.ANSWER, initial=initial, after=self.genuine_after()), "snapshot_contract_invalid") + + def test_retargeted_seed_fails_closed(self) -> None: + # ground_truth.py re-derives the target from the initial snapshot: a seed whose + # specialties no longer select the target must fail closed, never grade against stale constants. + initial = State() + initial.extra_sql.append("UPDATE doctors SET primary_specialty_id = 10, secondary_specialty_id = NULL") + after = self.genuine_after() + after.extra_sql.append("UPDATE doctors SET primary_specialty_id = 10, secondary_specialty_id = NULL") + self.assertFailsOn(self.verdict(self.GENUINE_STEPS, self.ANSWER, initial=initial, after=after), "snapshot_contract_invalid") diff --git a/sites/webmd_doctor/verify/tests/test_verify_0.py b/sites/webmd_doctor/verify/tests/test_verify_0.py new file mode 100644 index 00000000..2c041db1 --- /dev/null +++ b/sites/webmd_doctor/verify/tests/test_verify_0.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, login_steps, only_paths, profile, signup_steps, step, +) + +SLUG = "jonah-dimitriou-c4ce067c" +GENUINE_STEPS = [ + step("/"), + step("/", "input", "Dermatologist"), + step("/results?q=Dermatologist&loc=Newark%2C+DE+19711"), + step(profile(SLUG), "done"), +] +ANSWER = "Dr. Dimitriou graduated from Chesapeake Bay School of Medicine in 2004." + + +def genuine_after() -> State: + return State() + +class VerifyTask0Tests(SharedVerifierTests, VerifierTestCase): + N = 0 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def genuine_after(self) -> State: + return genuine_after() + + + def test_shortcut_fails_on_gate(self) -> None: + steps = [step("/"), step("/results?q=Dermatologist", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_profile_" + SLUG) + + def test_missing_results_visit_fails(self) -> None: + steps = [step("/"), step(profile(SLUG), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_results_dermatologist_search") + + def test_other_city_search_fails_gate(self) -> None: + steps = [step("/"), step("/results?q=Dermatologist&loc=Wilmington%2C+DE"), step(profile(SLUG), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_results_dermatologist_search") + + def test_same_surname_profile_fails(self) -> None: + steps = [step("/"), step("/results?q=Dermatologists"), step(profile("timothy-dimitriou-00000000"), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_profile_" + SLUG) + + def test_sids_and_tab_alias_pass(self) -> None: + steps = [step("/"), step("/results?sids=1"), step("/doctor/" + SLUG + "-locations", "done")] + self.assertPasses(self.verdict(steps, "Chesapeake Bay School Of Medicine, class of 2004")) + + def test_wrong_answer_0_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Dr. Dimitriou graduated from Great Falls University Hospital in 2004.', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_medical_school') + + def test_wrong_answer_1_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Chesapeake Bay School of Medicine, 2008', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_graduation_year') + + def test_read_only_write_fails(self) -> None: + after = genuine_after() + after.add_saved(2, 1) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "read_only_saved_providers_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/webmd_doctor/verify/tests/test_verify_1.py b/sites/webmd_doctor/verify/tests/test_verify_1.py new file mode 100644 index 00000000..5d3395d1 --- /dev/null +++ b/sites/webmd_doctor/verify/tests/test_verify_1.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, login_steps, only_paths, profile, signup_steps, step, +) + +SLUG = "julian-zamora-d412b77d" +GENUINE_STEPS = [ + step("/"), + step("/providers/specialty/cardiovascular-disease"), + step("/providers/specialty/cardiovascular-disease/delaware"), + step("/providers/specialty/cardiovascular-disease/delaware/wilmington"), + step(profile(SLUG), "done"), +] +ANSWER = "NPI 1438496704; languages: English, Tagalog and Portuguese." + + +def genuine_after() -> State: + return State() + +class VerifyTask1Tests(SharedVerifierTests, VerifierTestCase): + N = 1 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def genuine_after(self) -> State: + return genuine_after() + + + def test_shortcut_fails_on_gate(self) -> None: + steps = [step("/"), step("/results?q=Cardiologist", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_profile_" + SLUG) + + def test_other_zamora_profile_fails(self) -> None: + steps = [step("/"), step(profile("ana-zamora-00000000"), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_profile_" + SLUG) + + def test_grouped_npi_passes(self) -> None: + steps = [step("/"), step("/results?q=Cardiologist&loc=Wilmington%2C+DE"), step(profile(SLUG), "done")] + self.assertPasses(self.verdict(steps, "NPI: 1438 496 704. Speaks English, Tagalog, Portuguese.")) + + def test_wrong_answer_0_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'NPI 1438496705; English, Tagalog, Portuguese', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_npi') + + def test_wrong_answer_1_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'NPI 1438496704; English and Tagalog', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_lists_languages') + + def test_wrong_answer_2_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'NPI 1438496704; English, Tagalog, not Portuguese', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_lists_languages') + + def test_read_only_write_fails(self) -> None: + after = genuine_after() + after.add_saved(2, 28) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "read_only_saved_providers_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/webmd_doctor/verify/tests/test_verify_10.py b/sites/webmd_doctor/verify/tests/test_verify_10.py new file mode 100644 index 00000000..9ff50873 --- /dev/null +++ b/sites/webmd_doctor/verify/tests/test_verify_10.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, login_steps, only_paths, profile, signup_steps, step, +) + +SLUG = "veronica-nwachukwu-71051d38" +RESULTS = "/results?q=Psychiatrist&loc=Newark%2C+DE+19711&medicaid=true&minrating=4" +GENUINE_STEPS = [ + step("/"), + step("/results?q=Psychiatrist&loc=Newark%2C+DE+19711"), + step("/results?q=Psychiatrist&loc=Newark%2C+DE+19711&medicaid=true"), + step(RESULTS), + step(profile(SLUG), "done"), +] +ANSWER = "Average wait time 25 minutes; residency at Rappahannock University Hospital." + + +def genuine_after() -> State: + return State() + +class VerifyTask10Tests(SharedVerifierTests, VerifierTestCase): + N = 10 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def genuine_after(self) -> State: + return genuine_after() + + + def test_shortcut_fails_on_gate(self) -> None: + steps = [step("/"), step(RESULTS, "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_profile_" + SLUG) + + def test_rating_filter_missing_fails(self) -> None: + steps = [step("/"), step("/results?q=Psychiatrist&medicaid=true"), step(profile(SLUG), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_medicaid_rated_results") + + def test_medicaid_filter_missing_fails(self) -> None: + steps = [step("/"), step("/results?q=Psychiatrist&minrating=4"), step(profile(SLUG), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_medicaid_rated_results") + + def test_wrong_candidate_profile_fails(self) -> None: + steps = [step("/"), step(RESULTS), step(profile("luis-corwin-92a05b04"), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_profile_" + SLUG) + + def test_wrong_answer_0_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Average wait time 45 minutes; residency Rappahannock University Hospital', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_wait_minutes') + + def test_wrong_answer_1_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Average wait time 25 minutes; residency Tuckahoe College of Osteopathic Medicine', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_residency') + + def test_read_only_write_fails(self) -> None: + after = genuine_after() + after.add_saved(2, 129) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "read_only_saved_providers_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/webmd_doctor/verify/tests/test_verify_11.py b/sites/webmd_doctor/verify/tests/test_verify_11.py new file mode 100644 index 00000000..9c1d254a --- /dev/null +++ b/sites/webmd_doctor/verify/tests/test_verify_11.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, login_steps, only_paths, profile, signup_steps, step, +) + +SLUG = "sean-blackwood-45e84c50" +RESULTS = "/results?q=Family+Medicine&loc=Newark%2C+DE+19711&d=10&sortby=num_rating" +GENUINE_STEPS = [ + step("/"), + step("/results?q=Family+Medicine&loc=Newark%2C+DE+19711"), + step("/results?q=Family+Medicine&loc=Newark%2C+DE+19711&d=10"), + step(RESULTS), + step(profile(SLUG), "done"), +] +ANSWER = "NPI 1472790926; residency at Elk Neck Medical Center." + + +def genuine_after() -> State: + return State() + +class VerifyTask11Tests(SharedVerifierTests, VerifierTestCase): + N = 11 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def genuine_after(self) -> State: + return genuine_after() + + + def test_shortcut_fails_on_gate(self) -> None: + steps = [step("/"), step(RESULTS, "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_profile_" + SLUG) + + def test_default_distance_fails(self) -> None: + steps = [step("/"), step("/results?q=Family+Medicine&sortby=num_rating"), step(profile(SLUG), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_results_10mi_sorted_by_ratings") + + def test_wrong_sort_fails(self) -> None: + steps = [step("/"), step("/results?q=Family+Medicine&d=10&sortby=avg_rating"), step(profile(SLUG), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_results_10mi_sorted_by_ratings") + + def test_top_ranked_profile_fails(self) -> None: + steps = [step("/"), step(RESULTS), step(profile("aaron-harrington-6eddea1f"), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_profile_" + SLUG) + + def test_wrong_answer_0_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'NPI 1472790925; Elk Neck Medical Center', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_npi') + + def test_wrong_answer_1_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'NPI 1472790926; Schuylkill Medical Center', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_residency') + + def test_read_only_write_fails(self) -> None: + after = genuine_after() + after.add_saved(2, 53) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "read_only_saved_providers_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/webmd_doctor/verify/tests/test_verify_12.py b/sites/webmd_doctor/verify/tests/test_verify_12.py new file mode 100644 index 00000000..ee3d1ef1 --- /dev/null +++ b/sites/webmd_doctor/verify/tests/test_verify_12.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, login_steps, only_paths, profile, signup_steps, step, +) + +SLUG = "joseph-iyer-29b88273" +CITY = "/providers/specialty/cardiovascular-disease/pennsylvania/west-chester" +GENUINE_STEPS = [ + step("/"), + step("/providers/specialty/cardiovascular-disease"), + step("/providers/specialty/cardiovascular-disease/pennsylvania"), + step(CITY), + step(CITY + "?minrating=4"), + step(profile(SLUG), "done"), +] +ANSWER = "Fellowship at Allegheny Ridge Medical Center, completed in 1987." + + +def genuine_after() -> State: + return State() + +class VerifyTask12Tests(SharedVerifierTests, VerifierTestCase): + N = 12 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def genuine_after(self) -> State: + return genuine_after() + + + def test_shortcut_fails_on_gate(self) -> None: + steps = [step("/"), step("/results?q=Cardiologist&loc=West+Chester%2C+PA&minrating=4"), step(profile(SLUG), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_specialty_landing_page") + + def test_city_page_without_rating_filter_fails(self) -> None: + steps = [step("/"), step("/providers/specialty/cardiovascular-disease"), step("/providers/specialty/cardiovascular-disease/pennsylvania"), + step(CITY), step(profile(SLUG), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_city_page_rated_4_up") + + def test_gender_filter_too_passes(self) -> None: + steps = GENUINE_STEPS[:-1] + [step(CITY + "?minrating=4&gender=m"), step(profile(SLUG), "done")] + self.assertPasses(self.verdict(steps, ANSWER)) + + def test_wrong_male_profile_fails(self) -> None: + steps = GENUINE_STEPS[:-1] + [step(profile("jamal-sterling-d2de7bf5"), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_profile_" + SLUG) + + def test_wrong_answer_0_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Fellowship at Delmarva Bay Medical Center, 1987', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_fellowship') + + def test_wrong_answer_1_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Fellowship at Allegheny Ridge Medical Center, 1984', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_fellowship_year') + + def test_read_only_write_fails(self) -> None: + after = genuine_after() + after.add_saved(2, 39) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "read_only_saved_providers_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/webmd_doctor/verify/tests/test_verify_13.py b/sites/webmd_doctor/verify/tests/test_verify_13.py new file mode 100644 index 00000000..9dabfc65 --- /dev/null +++ b/sites/webmd_doctor/verify/tests/test_verify_13.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, login_steps, only_paths, profile, signup_steps, step, +) + +WINNER = "emerson-huang-f6afead5" +OTHER = "gregory-greenwood-34670192" +GENUINE_STEPS = [ + step("/"), + step("/providers/specialty/dermatology"), + step("/providers/specialty/dermatology/delaware"), + step("/providers/specialty/dermatology/delaware/wilmington"), + step(profile(OTHER)), + step("/providers/specialty/dermatology/delaware/wilmington"), + step(profile(WINNER), "done"), +] +ANSWER = "Dr. Emerson Huang graduated earlier, in 1992 (Dr. Greenwood graduated in 1997)." + + +def genuine_after() -> State: + return State() + +class VerifyTask13Tests(SharedVerifierTests, VerifierTestCase): + N = 13 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def genuine_after(self) -> State: + return genuine_after() + + + def test_shortcut_fails_on_gate(self) -> None: + steps = [step("/"), step("/providers/specialty/dermatology/delaware/wilmington", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_profile_" + WINNER) + + def test_only_winner_profile_fails(self) -> None: + steps = [step("/"), step(profile(WINNER), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_profile_" + OTHER) + + def test_same_surname_other_doctor_fails(self) -> None: + steps = [step("/"), step(profile(OTHER)), step(profile("tariq-huang-c8120504"), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_profile_" + WINNER) + + def test_wrong_answer_0_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Dr. Gregory Greenwood graduated earlier, in 1997.', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_names_earlier_graduate') + + def test_wrong_answer_1_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Dr. Huang, 1992', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_names_earlier_graduate') + + def test_wrong_answer_2_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Emerson Huang, DO - 1997', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_graduation_year') + + def test_read_only_write_fails(self) -> None: + after = genuine_after() + after.add_saved(2, 7) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "read_only_saved_providers_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/webmd_doctor/verify/tests/test_verify_14.py b/sites/webmd_doctor/verify/tests/test_verify_14.py new file mode 100644 index 00000000..1fa0e85d --- /dev/null +++ b/sites/webmd_doctor/verify/tests/test_verify_14.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, login_steps, only_paths, profile, signup_steps, step, +) + +WINNER = "arjun-bouchard-f3c85053" +OTHER = "colin-ellery-640b0a4a" +HOSPITAL = "/hospital/christina-creek-medical-center" +GENUINE_STEPS = [ + step("/"), + step("/hospitals"), + step("/hospitals/delaware"), + step(HOSPITAL), + step(HOSPITAL + "?specialty=psychiatry"), + step(profile(WINNER)), + step(HOSPITAL + "?specialty=psychiatry"), + step(profile(OTHER), "done"), +] +ANSWER = "Dr. Arjun Bouchard was certified more recently (2004; Dr. Ellery in 2000)." + + +def genuine_after() -> State: + return State() + +class VerifyTask14Tests(SharedVerifierTests, VerifierTestCase): + N = 14 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def genuine_after(self) -> State: + return genuine_after() + + + def test_shortcut_fails_on_gate(self) -> None: + steps = [step("/"), step("/results?q=Psychiatrist"), step(profile(WINNER)), step(profile(OTHER), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_delaware_hospitals_page") + + def test_hospital_page_skipped_fails(self) -> None: + steps = [step("/"), step("/hospitals/delaware"), step(profile(WINNER)), step(profile(OTHER), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_hospital_page") + + def test_only_one_profile_fails(self) -> None: + steps = [step("/"), step("/hospitals/delaware"), step(HOSPITAL), step(profile(WINNER), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_profile_" + OTHER) + + def test_other_specialty_pair_fails(self) -> None: + steps = [step("/"), step("/hospitals/delaware"), step(HOSPITAL), step(profile("kevin-hargrove-270f73e2")), step(profile("quinn-pereira-219e1bc1"), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_profile_" + WINNER) + + def test_paginated_roster_passes(self) -> None: + steps = [step("/"), step("/hospitals/delaware"), step(HOSPITAL), step(HOSPITAL + "?pagenumber=2"), step(profile(WINNER)), step(profile(OTHER), "done")] + self.assertPasses(self.verdict(steps, ANSWER)) + + def test_wrong_answer_0_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Dr. Colin Ellery was certified more recently, in 2000.', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_names_more_recent_certification') + + def test_wrong_answer_1_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Arjun Bouchard, certified 1997', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_certification_year') + + def test_read_only_write_fails(self) -> None: + after = genuine_after() + after.add_saved(2, 121) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "read_only_saved_providers_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/webmd_doctor/verify/tests/test_verify_15.py b/sites/webmd_doctor/verify/tests/test_verify_15.py new file mode 100644 index 00000000..ac7eed6f --- /dev/null +++ b/sites/webmd_doctor/verify/tests/test_verify_15.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, login_steps, only_paths, profile, signup_steps, step, +) + +SLUG = "linda-merriweather-378753c8" +PRACTICE = "/practice/rose-tree-orthopedics-sports-medicine" +GENUINE_STEPS = [ + step("/"), + step("/choice-awards"), + step("/choice-awards/awardrecipients?award-class=patient"), + step("/choice-awards/awardrecipients?award-class=patient&page=2"), + step(profile(SLUG)), + step(PRACTICE, "done"), +] +ANSWER = "Website: https://www.rosetreeorthopedicssportsmedicine.example - Saturday 9:00 am - 2:00 pm." + + +def genuine_after() -> State: + return State() + +class VerifyTask15Tests(SharedVerifierTests, VerifierTestCase): + N = 15 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def genuine_after(self) -> State: + return genuine_after() + + + def test_shortcut_fails_on_gate(self) -> None: + steps = [step("/"), step(profile(SLUG)), step(PRACTICE, "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_award_page") + + def test_wrong_award_class_fails(self) -> None: + steps = [step("/"), step("/choice-awards"), step("/choice-awards/awardrecipients?award-class=elite"), step(profile(SLUG)), step(PRACTICE, "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_patients_choice_recipients_page") + + def test_practice_page_skipped_fails(self) -> None: + steps = [step("/"), step("/choice-awards"), step("/choice-awards/awardrecipients"), step(profile(SLUG), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_practice_page") + + def test_default_award_class_and_state_filter_pass(self) -> None: + steps = [step("/"), step("/choice-awards"), step("/choice-awards/awardrecipients"), step("/choice-awards/awardrecipients?award-class=patient&state=pennsylvania"), + step(profile(SLUG)), step(PRACTICE, "done")] + self.assertPasses(self.verdict(steps, "rosetreeorthopedicssportsmedicine.example, Sat 9 AM-2 PM")) + + def test_wrong_answer_0_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Website: https://www.rosetreeorthopedicssportsmedicine.com - Saturday 9:00 am - 2:00 pm', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_practice_website') + + def test_wrong_answer_1_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Website: https://www.rosetreeorthopedicssportsmedicine.example - Saturday 8:00 am - 1:00 pm', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_saturday_hours') + + def test_read_only_write_fails(self) -> None: + after = genuine_after() + after.add_saved(2, 100) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "read_only_saved_providers_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/webmd_doctor/verify/tests/test_verify_16.py b/sites/webmd_doctor/verify/tests/test_verify_16.py new file mode 100644 index 00000000..ff794ec8 --- /dev/null +++ b/sites/webmd_doctor/verify/tests/test_verify_16.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, login_steps, only_paths, profile, signup_steps, step, +) + +SLUG = "anita-castellano-14da1f29" +GENUINE_STEPS = [ + step("/"), + *login_steps("bob.c@test.com"), + step("/results?q=Pediatrician&loc=Newark%2C+DE+19711"), + step(profile(SLUG), "click"), + step(profile(SLUG)), + step("/account/saved", "done"), +] +ANSWER = "Saved. Dr. Anita Castellano now appears under Saved Providers." + + +def genuine_after() -> State: + after = State() + after.add_saved(2, 163) + return after + +class VerifyTask16Tests(SharedVerifierTests, VerifierTestCase): + N = 16 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def genuine_after(self) -> State: + return genuine_after() + + + def test_shortcut_fails_on_gate(self) -> None: + steps = [step("/"), *login_steps("bob.c@test.com"), step("/results?q=Pediatrician"), step("/account/saved", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER, after=genuine_after()), "visited_profile_" + SLUG) + + def test_saved_page_before_profile_fails_order(self) -> None: + steps = [step("/"), *login_steps("bob.c@test.com"), step("/account/saved"), step("/results?q=Pediatrician"), step(profile(SLUG), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER, after=genuine_after()), "workflow_in_order") + + def test_state_unchanged_fails(self) -> None: + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=State()), "new_saved_row_belongs_to_bob") + + def test_saved_under_other_account_fails(self) -> None: + after = State() + after.add_saved(1, 163) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "new_saved_row_belongs_to_bob") + + def test_saved_wrong_doctor_fails(self) -> None: + after = State() + after.add_saved(2, 5) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "new_saved_row_belongs_to_bob") + + def test_extra_save_fails(self) -> None: + after = genuine_after() + after.add_saved(2, 5) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "new_saved_row_belongs_to_bob") + + def test_wrong_account_fails(self) -> None: + steps = [step("/"), *login_steps("alice.j@test.com"), *GENUINE_STEPS[4:]] + self.assertFailsOn(self.verdict(steps, ANSWER, after=genuine_after()), "entered_expected_account_email") + + def test_collateral_write_fails(self) -> None: + after = genuine_after() + after.add_review(2, 163, 5, "Collateral review text that is long enough to pass.") + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "user_reviews_unchanged") + + def test_wrong_answer_0_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Done.', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_confirms_saved') + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/webmd_doctor/verify/tests/test_verify_17.py b/sites/webmd_doctor/verify/tests/test_verify_17.py new file mode 100644 index 00000000..f1cdc870 --- /dev/null +++ b/sites/webmd_doctor/verify/tests/test_verify_17.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, login_steps, only_paths, profile, signup_steps, step, +) + +SLUG = "sarah-keller-f85bed81" +BOOKING = "/doctor/" + SLUG + "/bookappointment" +REFERENCE = "WMD-AB2CD3EF" +GENUINE_STEPS = [ + step("/"), + *login_steps("carol.d@test.com"), + step("/results?q=Cardiologist&loc=Newark%2C+DE+19711"), + step("/results?q=Cardiologist&loc=Newark%2C+DE+19711&page=2"), + step(profile(SLUG), "click"), + step(BOOKING + "?location_id=32&patient_type=New+Patient&slot=2026-09-14%7C10%3A30+AM", "click"), + step(BOOKING, "done"), +] +ANSWER = "Appointment requested. Confirmation reference: WMD-AB2CD3EF" + + +def genuine_after() -> State: + after = State() + after.add_appointment(3, 22, 32, reference=REFERENCE) + return after + +class VerifyTask17Tests(SharedVerifierTests, VerifierTestCase): + N = 17 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def genuine_after(self) -> State: + return genuine_after() + + + def test_shortcut_fails_on_gate(self) -> None: + steps = [step("/"), *login_steps("carol.d@test.com"), step(profile(SLUG), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER, after=genuine_after()), "visited_booking_page") + + def test_state_unchanged_fails(self) -> None: + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=State()), "exactly_one_new_request") + + def test_two_requests_fail(self) -> None: + after = genuine_after() + after.add_appointment(3, 22, 32, reference="WMD-ZZ2ZZ3ZZ") + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "exactly_one_new_request") + + def test_primary_office_fails(self) -> None: + after = State() + after.add_appointment(3, 22, 31, reference=REFERENCE) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "new_request_at_named_office") + + def test_returning_patient_fails(self) -> None: + after = State() + after.add_appointment(3, 22, 32, patient_type="Returning Patient", reference=REFERENCE) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "new_request_is_new_patient") + + def test_wrong_slot_fails(self) -> None: + after = State() + after.add_appointment(3, 22, 32, slot_time="10:00 AM", reference=REFERENCE) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "new_request_slot_matches") + after = State() + after.add_appointment(3, 22, 32, slot_date="2026-09-15", reference=REFERENCE) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "new_request_slot_matches") + + def test_other_account_fails(self) -> None: + after = State() + after.add_appointment(1, 22, 32, reference=REFERENCE) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "new_request_belongs_to_carol") + + def test_seed_reference_reported_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, "Confirmation reference WMD-JASDV25V", after=genuine_after()) + self.assertFailsOn(verdict, "answer_has_matching_reference") + + def test_extra_reference_in_answer_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, "References: WMD-AB2CD3EF and WMD-JASDV25V", after=genuine_after()) + self.assertFailsOn(verdict, "answer_has_no_other_reference") + + def test_dash_variant_reference_passes(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, "Reference: wmd–ab2cd3ef", after=genuine_after())) + + def test_wrong_answer_0_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Appointment requested. Confirmation reference: WMD-AB2CD3EG', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_matching_reference') + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/webmd_doctor/verify/tests/test_verify_18.py b/sites/webmd_doctor/verify/tests/test_verify_18.py new file mode 100644 index 00000000..1e08f2ae --- /dev/null +++ b/sites/webmd_doctor/verify/tests/test_verify_18.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, login_steps, only_paths, profile, signup_steps, step, +) + +SLUG = "tariq-huang-c8120504" +TEXT = "Short wait and a clear explanation of my treatment options." +GENUINE_STEPS = [ + step("/"), + *login_steps("david.k@test.com"), + step("/providers/specialty/dermatology"), + step("/providers/specialty/dermatology/maryland"), + step("/providers/specialty/dermatology/maryland/elkton"), + step(profile(SLUG), "click"), + step(profile(SLUG), "input", TEXT), + step(profile(SLUG), "click"), + step(profile(SLUG), "done"), +] +ANSWER = "Review submitted; the profile shows it as Pending review." + + +def genuine_after() -> State: + after = State() + after.add_review(4, 18, 4, TEXT) + return after + +class VerifyTask18Tests(SharedVerifierTests, VerifierTestCase): + N = 18 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def genuine_after(self) -> State: + return genuine_after() + + + def test_shortcut_fails_on_gate(self) -> None: + steps = [step("/"), *login_steps("david.k@test.com"), step("/results?q=Dermatologist", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER, after=genuine_after()), "visited_profile_" + SLUG) + + def test_ended_elsewhere_fails(self) -> None: + steps = GENUINE_STEPS[:-1] + [step("/account/saved", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER, after=genuine_after()), "ended_on_profile") + + def test_state_unchanged_fails(self) -> None: + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=State()), "exactly_one_new_review") + + def test_wrong_rating_fails(self) -> None: + after = State() + after.add_review(4, 18, 5, TEXT) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "new_review_rating_is_4") + + def test_paraphrased_text_fails(self) -> None: + after = State() + after.add_review(4, 18, 4, "Short wait and a clear explanation of treatment options.") + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "new_review_text_matches") + + def test_trailing_period_dropped_passes(self) -> None: + after = State() + after.add_review(4, 18, 4, "Short wait and a clear explanation of my treatment options") + self.assertPasses(self.verdict(GENUINE_STEPS, ANSWER, after=after)) + + def test_review_on_same_surname_doctor_fails(self) -> None: + after = State() + after.add_review(4, 7, 4, TEXT) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "new_review_is_for_target") + + def test_other_account_fails(self) -> None: + after = State() + after.add_review(1, 18, 4, TEXT) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "new_review_belongs_to_david") + + def test_two_reviews_fail(self) -> None: + after = genuine_after() + after.add_review(4, 18, 4, TEXT) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "exactly_one_new_review") + + def test_wrong_answer_0_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Review submitted successfully.', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_confirms_pending') + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/webmd_doctor/verify/tests/test_verify_19.py b/sites/webmd_doctor/verify/tests/test_verify_19.py new file mode 100644 index 00000000..7fc7e3cb --- /dev/null +++ b/sites/webmd_doctor/verify/tests/test_verify_19.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, login_steps, only_paths, profile, signup_steps, step, +) + +SLUG = "monica-carrington-62f5d8a2" +EMAIL = "new.user@example.com" +GENUINE_STEPS = [ + step("/"), + *signup_steps(EMAIL), + step("/results?q=Neurologist&loc=Newark%2C+DE+19711"), + step("/results?q=Neurologist&loc=Newark%2C+DE+19711&isvirtualvisit=true"), + step(profile(SLUG), "click"), + step(profile(SLUG), "done"), +] +ANSWER = "Registered and saved the provider. NPI: 1279956602" + + +def genuine_after() -> State: + after = State() + user_id = after.add_user(EMAIL) + after.add_saved(user_id, 68) + return after + +class VerifyTask19Tests(SharedVerifierTests, VerifierTestCase): + N = 19 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def genuine_after(self) -> State: + return genuine_after() + + + def test_shortcut_fails_on_gate(self) -> None: + steps = [step("/"), *login_steps("alice.j@test.com"), step("/results?q=Neurologist&isvirtualvisit=true"), step(profile(SLUG), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER, after=genuine_after()), "visited_signup_page") + + def test_virtual_filter_missing_fails(self) -> None: + steps = [step("/"), *signup_steps(EMAIL), step("/results?q=Neurologist"), step(profile(SLUG), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER, after=genuine_after()), "visited_virtual_neurologist_results") + + def test_virtual_typed_in_search_passes(self) -> None: + steps = [step("/"), *signup_steps(EMAIL), step("/results?q=Neurologist+virtual+visit"), step(profile(SLUG), "done")] + self.assertPasses(self.verdict(steps, ANSWER, after=genuine_after())) + + def test_no_registration_fails(self) -> None: + after = State() + after.add_saved(1, 68) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "exactly_one_new_user") + + def test_registered_with_other_email_fails(self) -> None: + after = State() + user_id = after.add_user("someone@else.com") + after.add_saved(user_id, 68) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "new_user_email_matches_signup_input") + + def test_registered_but_not_saved_fails(self) -> None: + after = State() + after.add_user(EMAIL) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "exactly_one_new_saved_row") + + def test_saved_under_seed_account_fails(self) -> None: + after = State() + after.add_user(EMAIL) + after.add_saved(1, 68) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "new_saved_row_belongs_to_new_user") + + def test_saved_wrong_doctor_fails(self) -> None: + after = State() + user_id = after.add_user(EMAIL) + after.add_saved(user_id, 61) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "new_saved_row_is_for_target") + + def test_two_accounts_fail(self) -> None: + after = genuine_after() + after.add_user("second@example.com") + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "exactly_one_new_user") + + def test_wrong_answer_0_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Registered and saved the provider. NPI: 1279956603', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_npi') + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/webmd_doctor/verify/tests/test_verify_2.py b/sites/webmd_doctor/verify/tests/test_verify_2.py new file mode 100644 index 00000000..783cc725 --- /dev/null +++ b/sites/webmd_doctor/verify/tests/test_verify_2.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, login_steps, only_paths, profile, signup_steps, step, +) + +SLUG = "ruth-thackeray-45234b97" +GENUINE_STEPS = [ + step("/"), + step("/results?q=Family+Medicine&loc=Newark%2C+DE+19711"), + step(profile(SLUG), "done"), +] +ANSWER = "Phone (302) 555-1542; Saturday hours 8:00 am - 1:00 pm." + + +def genuine_after() -> State: + return State() + +class VerifyTask2Tests(SharedVerifierTests, VerifierTestCase): + N = 2 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def genuine_after(self) -> State: + return genuine_after() + + + def test_shortcut_fails_on_gate(self) -> None: + steps = [step("/"), step("/results?q=Family+Medicine", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_profile_" + SLUG) + + def test_missing_results_visit_fails(self) -> None: + steps = [step("/"), step(profile(SLUG), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_results_family_medicine_search") + + def test_phone_and_time_variants_pass(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, "302-555-1542, Sat 8 AM to 1 PM")) + + def test_wrong_answer_0_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Phone (302) 555-4822; Saturday 8:00 am - 1:00 pm', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_primary_office_phone') + + def test_wrong_answer_1_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Phone (302) 555-1542; Saturday 9:00 am - 5:00 pm', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_saturday_hours') + + def test_wrong_answer_2_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Phone (302) 555-1542; closed on Saturday', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_saturday_hours') + + def test_read_only_write_fails(self) -> None: + after = genuine_after() + after.add_saved(2, 48) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "read_only_saved_providers_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/webmd_doctor/verify/tests/test_verify_3.py b/sites/webmd_doctor/verify/tests/test_verify_3.py new file mode 100644 index 00000000..06f70fb9 --- /dev/null +++ b/sites/webmd_doctor/verify/tests/test_verify_3.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, login_steps, only_paths, profile, signup_steps, step, +) + +SLUG = "mateo-alvarado-f727bd61" +GENUINE_STEPS = [ + step("/"), + step("/providers/specialty/neurology"), + step("/providers/specialty/neurology/pennsylvania"), + step("/providers/specialty/neurology/pennsylvania/west-chester"), + step(profile(SLUG), "done"), +] +ANSWER = "Providence Road Medical Group - Professional Plaza, 3543 Baltimore Pike Bldg B, Media, PA" + + +def genuine_after() -> State: + return State() + +class VerifyTask3Tests(SharedVerifierTests, VerifierTestCase): + N = 3 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def genuine_after(self) -> State: + return genuine_after() + + + def test_shortcut_fails_on_gate(self) -> None: + steps = [step("/"), step("/providers/specialty/neurology/pennsylvania/west-chester", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_profile_" + SLUG) + + def test_dash_variants_pass(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, "Providence Road Medical Group – Professional Plaza at 3543 Baltimore Pike")) + + def test_wrong_answer_0_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Goose Creek Neurology Group, 1315 W Chester Pike Ste 300', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_other_office_name') + + def test_wrong_answer_1_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Providence Road Medical Group - Professional Plaza, 3534 Baltimore Pike', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_other_office_street') + + def test_read_only_write_fails(self) -> None: + after = genuine_after() + after.add_saved(2, 75) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "read_only_saved_providers_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/webmd_doctor/verify/tests/test_verify_4.py b/sites/webmd_doctor/verify/tests/test_verify_4.py new file mode 100644 index 00000000..3ae65aa7 --- /dev/null +++ b/sites/webmd_doctor/verify/tests/test_verify_4.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, login_steps, only_paths, profile, signup_steps, step, +) + +SLUG = "charles-villanueva-13e969b2" +GENUINE_STEPS = [ + step("/"), + step("/providers/specialty/orthopedic-surgery"), + step("/providers/specialty/orthopedic-surgery/maryland"), + step("/providers/specialty/orthopedic-surgery/maryland/elkton"), + step(profile(SLUG), "done"), +] +ANSWER = "Certified by the American Board of Orthopaedic Surgery in 2014; residency at Blue Ridge Regional Medical Center." + + +def genuine_after() -> State: + return State() + +class VerifyTask4Tests(SharedVerifierTests, VerifierTestCase): + N = 4 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def genuine_after(self) -> State: + return genuine_after() + + + def test_shortcut_fails_on_gate(self) -> None: + steps = [step("/"), step("/results?q=Orthopedic+Surgeon&loc=Elkton%2C+MD", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_profile_" + SLUG) + + def test_american_spelling_passes(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, "American Board of Orthopedic Surgery (2014), residency: Blue Ridge Regional Medical Center")) + + def test_wrong_answer_0_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'American Board of Surgery, 2014, Blue Ridge Regional Medical Center', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_certifying_board') + + def test_wrong_answer_1_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'American Board of Orthopaedic Surgery, 2013, Blue Ridge Regional Medical Center', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_certification_year') + + def test_wrong_answer_2_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'American Board of Orthopaedic Surgery, 2014, Cumberland Ridge Hospital', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_residency') + + def test_read_only_write_fails(self) -> None: + after = genuine_after() + after.add_saved(2, 92) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "read_only_saved_providers_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/webmd_doctor/verify/tests/test_verify_5.py b/sites/webmd_doctor/verify/tests/test_verify_5.py new file mode 100644 index 00000000..6605a86b --- /dev/null +++ b/sites/webmd_doctor/verify/tests/test_verify_5.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, login_steps, only_paths, profile, signup_steps, step, +) + +SLUG = "caroline-danforth-7093653d" +GENUINE_STEPS = [ + step("/"), + step("/results?q=Gastroenterologist&loc=Newark%2C+DE+19711"), + step("/results?q=Gastroenterologist&loc=Newark%2C+DE+19711&page=2"), + step(profile(SLUG), "done"), +] +ANSWER = "More Than Most: Acid Reflux (GERD). First under View Top 20: Anemia." + + +def genuine_after() -> State: + return State() + +class VerifyTask5Tests(SharedVerifierTests, VerifierTestCase): + N = 5 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def genuine_after(self) -> State: + return genuine_after() + + + def test_shortcut_fails_on_gate(self) -> None: + steps = [step("/"), step("/results?q=Gastroenterologist", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_profile_" + SLUG) + + def test_missing_results_visit_fails(self) -> None: + steps = [step("/"), step(profile(SLUG), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_results_gastroenterologist_search") + + def test_listing_all_five_still_passes(self) -> None: + answer = ("Top five: Celiac Disease, Crohn's Disease, Irritable Bowel Syndrome, Acid Reflux (GERD), Hemorrhoids. " + "The one marked More Than Most is GERD; the first Top 20 entry is Anemia.") + self.assertPasses(self.verdict(GENUINE_STEPS, answer)) + + def test_wrong_answer_0_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'More Than Most: Celiac Disease. First top-20: Anemia.', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_more_than_most_condition') + + def test_wrong_answer_1_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'More Than Most: Acid Reflux (GERD). First top-20: Hepatitis C.', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_first_top20_condition') + + def test_read_only_write_fails(self) -> None: + after = genuine_after() + after.add_saved(2, 106) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "read_only_saved_providers_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/webmd_doctor/verify/tests/test_verify_6.py b/sites/webmd_doctor/verify/tests/test_verify_6.py new file mode 100644 index 00000000..f4269e35 --- /dev/null +++ b/sites/webmd_doctor/verify/tests/test_verify_6.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, login_steps, only_paths, profile, signup_steps, step, +) + +SLUG = "fatima-jensen-e5a26d53" +GENUINE_STEPS = [ + step("/"), + step("/providers/specialty/psychiatry"), + step("/providers/specialty/psychiatry/pennsylvania"), + step("/providers/specialty/psychiatry/pennsylvania/media"), + step(profile(SLUG)), + step(profile(SLUG) + "?rpage=2", "done"), +] +ANSWER = "The oldest review is dated November 2, 2022 and gave 4 stars." + + +def genuine_after() -> State: + return State() + +class VerifyTask6Tests(SharedVerifierTests, VerifierTestCase): + N = 6 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def genuine_after(self) -> State: + return genuine_after() + + + def test_shortcut_fails_on_gate(self) -> None: + steps = [step("/"), step("/providers/specialty/psychiatry/pennsylvania/media", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_profile_" + SLUG) + + def test_first_review_page_only_fails(self) -> None: + steps = [step("/"), step(profile(SLUG), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_reviews_page_2") + + def test_date_and_rating_variants_pass(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, "Oldest: Nov 2, 2022, rated 4/5")) + self.assertPasses(self.verdict(GENUINE_STEPS, "2022-11-02 - four stars")) + + def test_wrong_answer_0_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'The oldest review is dated February 16, 2023 with 4 stars.', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_oldest_review_date') + + def test_wrong_answer_1_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'November 2, 2022 - 5 stars', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_oldest_review_rating') + + def test_wrong_answer_2_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'November 2, 2022; the rating is not 4 stars', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_oldest_review_rating') + + def test_read_only_write_fails(self) -> None: + after = genuine_after() + after.add_saved(2, 138) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "read_only_saved_providers_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/webmd_doctor/verify/tests/test_verify_7.py b/sites/webmd_doctor/verify/tests/test_verify_7.py new file mode 100644 index 00000000..7244316c --- /dev/null +++ b/sites/webmd_doctor/verify/tests/test_verify_7.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, login_steps, only_paths, profile, signup_steps, step, +) + +SLUG = "lillian-acosta-4a89e15e" +GENUINE_STEPS = [ + step("/"), + step("/providers/specialty/obstetrics-gynecology"), + step("/providers/specialty/obstetrics-gynecology/new-jersey"), + step("/providers/specialty/obstetrics-gynecology/new-jersey/salem"), + step(profile(SLUG), "done"), +] +ANSWER = "Staff was courteous had the most needs-improvement votes; average wait time 15 minutes." + + +def genuine_after() -> State: + return State() + +class VerifyTask7Tests(SharedVerifierTests, VerifierTestCase): + N = 7 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def genuine_after(self) -> State: + return genuine_after() + + + def test_shortcut_fails_on_gate(self) -> None: + steps = [step("/"), step("/results?q=OBGYN&loc=Salem%2C+NJ", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_profile_" + SLUG) + + def test_wait_variants_pass(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, "Criterion: Staff was courteous (16). Wait: 15 min.")) + self.assertPasses(self.verdict(GENUINE_STEPS, "'Staff was courteous'; average wait time is 15")) + + def test_clock_time_is_not_wait_minutes(self) -> None: + verdict = self.verdict(GENUINE_STEPS, "Staff was courteous; office opens 9:15 am") + self.assertFailsOn(verdict, "answer_has_wait_minutes") + + def test_wrong_answer_0_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Gave a thorough Exam; average wait time 15 minutes', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_worst_criterion') + + def test_wrong_answer_1_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Staff was courteous; average wait time 45 minutes', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_wait_minutes') + + def test_read_only_write_fails(self) -> None: + after = genuine_after() + after.add_saved(2, 156) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "read_only_saved_providers_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/webmd_doctor/verify/tests/test_verify_8.py b/sites/webmd_doctor/verify/tests/test_verify_8.py new file mode 100644 index 00000000..e77de4c6 --- /dev/null +++ b/sites/webmd_doctor/verify/tests/test_verify_8.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, login_steps, only_paths, profile, signup_steps, step, +) + +SLUG = "adrian-navarro-b338ac81" +GENUINE_STEPS = [ + step("/"), + *login_steps("alice.j@test.com"), + step("/account/saved"), + step(profile(SLUG)), + step("/account/saved", "click"), + step("/account/saved", "done"), +] +ANSWER = "Residency: Piedmont Atlantic Hospital. Removed the provider from Saved Providers." + + +def genuine_after() -> State: + after = State() + after.remove_saved(1, 5) + return after + +class VerifyTask8Tests(SharedVerifierTests, VerifierTestCase): + N = 8 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def genuine_after(self) -> State: + return genuine_after() + + + def test_shortcut_fails_on_gate(self) -> None: + steps = [step("/"), *login_steps("alice.j@test.com"), step("/results?q=Dermatologist"), step(profile(SLUG), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER, after=genuine_after()), "visited_saved_providers_page") + + def test_saved_page_after_profile_only_fails_order(self) -> None: + steps = [step("/"), *login_steps("alice.j@test.com"), step(profile(SLUG)), step("/account/saved", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER, after=genuine_after()), "workflow_in_order") + + def test_wrong_account_fails(self) -> None: + steps = [step("/"), *login_steps("bob.c@test.com"), step("/account/saved"), step(profile(SLUG), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER, after=genuine_after()), "entered_expected_account_email") + + def test_state_unchanged_fails(self) -> None: + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=State()), "removed_target_only") + + def test_wrong_row_removed_fails(self) -> None: + after = State() + after.remove_saved(1, 47) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "removed_target_only") + + def test_two_rows_removed_fails(self) -> None: + after = genuine_after() + after.remove_saved(1, 47) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "removed_target_only") + + def test_collateral_write_fails(self) -> None: + after = genuine_after() + after.add_review(1, 5, 5, "Collateral review text that is long enough to pass.") + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "user_reviews_unchanged") + + def test_removed_via_profile_toggle_passes(self) -> None: + steps = [step("/"), *login_steps("alice.j@test.com"), step("/account/saved"), step(profile(SLUG), "click"), step(profile(SLUG), "done")] + self.assertPasses(self.verdict(steps, ANSWER, after=genuine_after())) + + def test_wrong_answer_0_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Residency: Rappahannock University Hospital. Removed.', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_residency') + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/webmd_doctor/verify/tests/test_verify_9.py b/sites/webmd_doctor/verify/tests/test_verify_9.py new file mode 100644 index 00000000..22f9fd68 --- /dev/null +++ b/sites/webmd_doctor/verify/tests/test_verify_9.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, login_steps, only_paths, profile, signup_steps, step, +) + +SLUG = "nicole-dubois-243c6e66" +RESULTS = "/results?q=Dermatologist&loc=Newark%2C+DE+19711&gender=f&newpatient=true&insuranceid=4" +GENUINE_STEPS = [ + step("/"), + step("/results?q=Dermatologist&loc=Newark%2C+DE+19711"), + step("/results?q=Dermatologist&loc=Newark%2C+DE+19711&gender=f"), + step("/results?q=Dermatologist&loc=Newark%2C+DE+19711&gender=f&newpatient=true"), + step(RESULTS), + step(profile(SLUG), "done"), +] +ANSWER = "Tuckahoe College of Osteopathic Medicine; board certified in 2024." + + +def genuine_after() -> State: + return State() + +class VerifyTask9Tests(SharedVerifierTests, VerifierTestCase): + N = 9 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def genuine_after(self) -> State: + return genuine_after() + + + def test_shortcut_fails_on_gate(self) -> None: + steps = [step("/"), step(RESULTS, "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_profile_" + SLUG) + + def test_filters_never_combined_fails(self) -> None: + steps = [step("/"), step("/results?q=Dermatologist&gender=f"), step("/results?q=Dermatologist&newpatient=true"), + step("/results?q=Dermatologist&insuranceid=4"), step(profile(SLUG), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_filtered_results") + + def test_insurer_missing_fails(self) -> None: + steps = [step("/"), step("/results?q=Dermatologist&gender=f&newpatient=true"), step(profile(SLUG), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_filtered_results") + + def test_insurer_typed_in_search_passes(self) -> None: + steps = [step("/"), step("/results?q=Dermatologist+Blue+Cross+Blue+Shield&gender=f&newpatient=1"), step(profile(SLUG), "done")] + self.assertPasses(self.verdict(steps, ANSWER)) + + def test_wrong_candidate_profile_fails(self) -> None: + steps = [step("/"), step(RESULTS), step(profile("celeste-bergstrom-a289f96e"), "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_profile_" + SLUG) + + def test_wrong_answer_0_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Patuxent River College of Medicine; certified 2024', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_medical_school') + + def test_wrong_answer_1_fails(self) -> None: + verdict = self.verdict(GENUINE_STEPS, 'Tuckahoe College of Osteopathic Medicine; certified 2020', after=genuine_after()) + self.assertFailsOn(verdict, 'answer_has_certification_year') + + def test_read_only_write_fails(self) -> None: + after = genuine_after() + after.add_saved(2, 17) + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "read_only_saved_providers_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/webmd_doctor/verify/tests/test_verify_lib.py b/sites/webmd_doctor/verify/tests/test_verify_lib.py new file mode 100644 index 00000000..0a891e4d --- /dev/null +++ b/sites/webmd_doctor/verify/tests/test_verify_lib.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import datetime as dt +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import verify_lib as lib # noqa: E402 +from _support import BASE, State, step # noqa: E402 + + +def traj(*steps: dict) -> dict: + return {"start_url": f"{BASE}/", "steps": list(steps)} + + +class NormalizeAndPhraseTests(unittest.TestCase): + def test_normalize_folds_case_dashes_and_ampersand(self) -> None: + self.assertEqual(lib.normalize_text("Rose Tree Orthopedics & Sports Medicine"), "rose tree orthopedics and sports medicine") + self.assertEqual(lib.normalize_text("Riverfront – Wellness"), "riverfront - wellness") + + def test_phrase_ignores_punctuation_and_dash_style(self) -> None: + self.assertTrue(lib.contains_institution("Office: Providence Road Medical Group – Professional Plaza.", "Providence Road Medical Group - Professional Plaza")) + self.assertTrue(lib.contains_institution("rose tree orthopedics and sports medicine", "Rose Tree Orthopedics & Sports Medicine")) + self.assertFalse(lib.contains_institution("Blue Ridge Medical Center", "Blue Ridge Regional Medical Center")) + + def test_phrase_requires_whole_tokens_and_affirmation(self) -> None: + self.assertFalse(lib.contains_phrase("Anemias", "Anemia")) + self.assertFalse(lib.contains_phrase("It is not Anemia.", "Anemia")) + self.assertFalse(lib.contains_phrase("Anemia is wrong; the answer is Hepatitis C", "Anemia")) + self.assertTrue(lib.contains_phrase("First entry: Anemia.", "Anemia")) + + def test_condition_accepts_abbreviation_or_long_form(self) -> None: + self.assertTrue(lib.contains_condition("marked More Than Most: GERD", "Acid Reflux (GERD)")) + self.assertTrue(lib.contains_condition("acid reflux", "Acid Reflux (GERD)")) + self.assertFalse(lib.contains_condition("Celiac Disease", "Acid Reflux (GERD)")) + + def test_doctor_name_needs_both_tokens(self) -> None: + self.assertTrue(lib.contains_doctor_name("Dr. Emerson Huang, DO", "Emerson", "Huang")) + self.assertTrue(lib.contains_doctor_name("Huang, Emerson", "Emerson", "Huang")) + self.assertFalse(lib.contains_doctor_name("Dr. Huang graduated first", "Emerson", "Huang")) + + +class NumberMatcherTests(unittest.TestCase): + def test_year(self) -> None: + self.assertTrue(lib.contains_year("graduated in 2004.", 2004)) + self.assertFalse(lib.contains_year("NPI 1200412345", 2004)) + self.assertFalse(lib.contains_year("not 2004 but 2008", 2004)) + + def test_npi(self) -> None: + self.assertTrue(lib.contains_npi("NPI: 1438496704", "1438496704")) + self.assertTrue(lib.contains_npi("NPI 1438 496 704", "1438496704")) + self.assertFalse(lib.contains_npi("NPI 14384967041", "1438496704")) + self.assertFalse(lib.contains_npi("NPI 1438496705", "1438496704")) + + def test_phone(self) -> None: + for text in ("(302) 555-1542", "302-555-1542", "302.555.1542", "+1 302 555 1542", "call 3025551542 now"): + self.assertTrue(lib.contains_phone(text, "(302) 555-1542"), text) + self.assertFalse(lib.contains_phone("(302) 555-4822", "(302) 555-1542")) + self.assertFalse(lib.contains_phone("13025551542999", "(302) 555-1542")) + + def test_hours_window(self) -> None: + for text in ("8:00 am - 1:00 pm", "8 AM to 1 PM", "08:00-13:00", "8:00 a.m. until 1:00 p.m."): + self.assertTrue(lib.contains_hours_window(text, "8:00 am", "1:00 pm"), text) + self.assertFalse(lib.contains_hours_window("8:00 am - 5:00 pm", "8:00 am", "1:00 pm")) + self.assertFalse(lib.contains_hours_window("Closed", "8:00 am", "1:00 pm")) + + def test_minutes_masks_clock_times(self) -> None: + self.assertTrue(lib.contains_minutes("Average wait time 15 minutes", 15)) + self.assertTrue(lib.contains_minutes("15 min", 15)) + self.assertTrue(lib.contains_minutes("a 15-minute wait", 15)) + self.assertTrue(lib.contains_minutes("average wait time: 15", 15)) + self.assertFalse(lib.contains_minutes("opens at 9:15 am", 15)) + self.assertFalse(lib.contains_minutes("wait 45 minutes", 15)) + self.assertFalse(lib.contains_minutes("wait 150 minutes", 15)) + + def test_star_rating(self) -> None: + for text in ("4 stars", "4-star", "rated 4/5", "4.0 out of 5", "four stars", "rating: 4", "★★★★"): + self.assertTrue(lib.contains_star_rating(text, 4), text) + for text in ("5 stars", "★★★★★", "November 4, 2022", "4 reviews", "not 4 stars"): + self.assertFalse(lib.contains_star_rating(text, 4), text) + + def test_review_date(self) -> None: + when = dt.date(2022, 11, 2) + for text in ("November 2, 2022", "Nov 2 2022", "Nov. 2nd, 2022", "2 November 2022", "2022-11-02", "11/2/2022", "11/02/22"): + self.assertTrue(lib.contains_review_date(text, when), text) + for text in ("November 3, 2022", "November 2, 2023", "December 2, 2022", "2022-11-20"): + self.assertFalse(lib.contains_review_date(text, when), text) + + def test_street(self) -> None: + self.assertTrue(lib.contains_street("3543 Baltimore Pike Bldg B", "3543 Baltimore Pike")) + self.assertTrue(lib.contains_street("2441 South Rock Road", "2441 S Rock Rd")) + self.assertFalse(lib.contains_street("3534 Baltimore Pike", "3543 Baltimore Pike")) + + def test_reference(self) -> None: + self.assertTrue(lib.contains_reference("Reference WMD-AB2CD3EF.", "WMD-AB2CD3EF")) + self.assertTrue(lib.contains_reference("wmd–ab2cd3ef", "WMD-AB2CD3EF")) + self.assertTrue(lib.contains_reference("WMD AB2CD3EF", "WMD-AB2CD3EF")) + self.assertFalse(lib.contains_reference("WMD-AB2CD3EFX", "WMD-AB2CD3EF")) + self.assertEqual(lib.extract_references("WMD-AB2CD3EF and WMD-JASDV25V"), {"WMD-AB2CD3EF", "WMD-JASDV25V"}) + + def test_url(self) -> None: + site = "https://www.rosetreeorthopedicssportsmedicine.example" + for text in (site, "rosetreeorthopedicssportsmedicine.example", "http://rosetreeorthopedicssportsmedicine.example/", "Website: www.rosetreeorthopedicssportsmedicine.example."): + self.assertTrue(lib.contains_url(text, site), text) + self.assertFalse(lib.contains_url("https://www.rosetreeorthopedicssportsmedicine.com", site)) + self.assertFalse(lib.contains_url("xrosetreeorthopedicssportsmedicine.example", site)) + + def test_review_text_matches(self) -> None: + expected = "Short wait and a clear explanation of my treatment options." + self.assertTrue(lib.review_text_matches("short wait and a clear explanation of my treatment options", expected)) + self.assertFalse(lib.review_text_matches("Short wait and a clear explanation of treatment options.", expected)) + + +class GateTests(unittest.TestCase): + def test_profile_paths_and_aliases(self) -> None: + slug = "jonah-dimitriou-c4ce067c" + self.assertTrue(lib.profile_visited(traj(step(f"/doctor/{slug}-overview")), slug)) + self.assertTrue(lib.profile_visited(traj(step(f"/doctor/{slug}-reviews")), slug)) + self.assertFalse(lib.profile_visited(traj(step(f"/doctor/{slug}/bookappointment")), slug)) + self.assertFalse(lib.profile_visited(traj(step("/doctor/timothy-dimitriou-00000000-overview")), slug)) + self.assertTrue(lib.profile_visited_with(traj(step(f"/doctor/{slug}-overview?rpage=2")), slug, rpage="2")) + self.assertFalse(lib.profile_visited_with(traj(step(f"/doctor/{slug}-overview")), slug, rpage="2")) + + def test_results_params(self) -> None: + t = traj(step("/results?q=Dermatologists&loc=Newark%2C+DE+19711&gender=f&newpatient=true&insuranceid=4")) + self.assertTrue(lib.results_visited(t, q=r"dermatolog", gender="f", newpatient=True, insuranceid="4", loc=lib.NEWARK)) + self.assertFalse(lib.results_visited(t, q=r"dermatolog", medicaid=True)) + self.assertFalse(lib.results_visited(t, q=r"psychiatr")) + self.assertTrue(lib.results_visited(traj(step("/results?sids=1")), sids=("1", "9"))) + self.assertTrue(lib.results_visited(traj(step("/results?q=Dermatologist+Blue+Cross")), q=r"(?=.*dermatolog)(?=.*blue cross)")) + + def test_newark_rule(self) -> None: + for url in ("/results?q=x", "/results?q=x&loc=19711", "/results?q=x&loc=Newark%2C+DE+19711", "/results?q=x&loc=newark", "/results?q=x&loc=Newark%2C+Delaware", "/results?q=x&zc=19711"): + self.assertTrue(lib.results_visited(traj(step(url)), loc=lib.NEWARK), url) + for url in ("/results?q=x&loc=Wilmington%2C+DE", "/results?q=x&loc=Newark%2C+NJ", "/results?q=x&city=Baltimore&state=MD", "/results?q=x&zc=19801"): + self.assertFalse(lib.results_visited(traj(step(url)), loc=lib.NEWARK), url) + + def test_absent_parameter_rule(self) -> None: + path = "/choice-awards/awardrecipients" + self.assertTrue(lib.results_visited(traj(step(path)), path=path, **{"award-class": ("patient", "")})) + self.assertTrue(lib.results_visited(traj(step(path + "?award-class=patient&page=2")), path=path, **{"award-class": ("patient", "")})) + self.assertFalse(lib.results_visited(traj(step(path + "?award-class=elite")), path=path, **{"award-class": ("patient", "")})) + + def test_paths_in_order_with_regex(self) -> None: + slug = "adrian-navarro-b338ac81" + ordered = traj(step("/login"), step("/account/saved"), step(f"/doctor/{slug}-overview")) + reversed_ = traj(step("/login"), step(f"/doctor/{slug}-overview"), step("/account/saved")) + requirements = [("/login", {}), ("/account/saved", {}), (lib.profile_path_pattern(slug), {})] + self.assertTrue(lib.check_paths_in_order(lib.Judge("t"), ordered, "order", requirements)) + self.assertFalse(lib.check_paths_in_order(lib.Judge("t"), reversed_, "order", requirements)) + + def test_signup_email_only_from_signup_steps(self) -> None: + t = traj(step("/login", "input", "alice.j@test.com"), step("/signup", "input", "new.user@example.com"), step("/signup", "input", "Secret123!")) + self.assertEqual(lib.signup_email(t), "new.user@example.com") + self.assertEqual(lib.trajectory_last_email(t), "new.user@example.com") + self.assertEqual(lib.signup_email(traj(step("/login", "input", "alice.j@test.com"))), "") + + def test_site_urls_accept_any_loopback_port(self) -> None: + self.assertTrue(lib.is_site_url("http://localhost:40024/results")) + self.assertTrue(lib.is_site_url("http://127.0.0.1:41024/")) + self.assertFalse(lib.is_site_url("https://doctor.webmd.com/results")) + + +class SnapshotContractTests(unittest.TestCase): + def test_seed_copies_validate_and_tamper_is_rejected(self) -> None: + import tempfile + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + initial = str(State().write(root / "initial.db")) + after = str(State().write(root / "after.db")) + lib._validate_snapshot_contract(initial, after) # no exception + bad = State() + bad.extra_sql.append("DELETE FROM specialties WHERE id = 10") + with self.assertRaises(ValueError): + lib._validate_snapshot_contract(initial, str(bad.write(root / "bad.db"))) + self.assertEqual(lib.table_delta(initial, after, "saved_providers"), {"added": [], "removed": [], "changed": []}) + changed = State() + changed.add_saved(2, 163) + delta = lib.table_delta(initial, str(changed.write(root / "changed.db")), "saved_providers") + self.assertEqual(len(delta["added"]), 1) + self.assertEqual(lib.saved_delta(initial, str(root / "changed.db"), 2), ({163}, set())) + self.assertEqual([row["doctor_id"] for row in lib.new_table_rows(initial, str(root / "changed.db"), "saved_providers")], [163]) + + def test_ground_truth_derives_every_task_from_the_seed(self) -> None: + import tempfile + + import ground_truth + + with tempfile.TemporaryDirectory() as directory: + seed = str(State().write(Path(directory) / "seed.db")) + facts = ground_truth.all_ground_truth(seed) + self.assertEqual(sorted(facts), list(range(20))) + self.assertEqual(facts[17]["location_id"], 32) + self.assertEqual(facts[13]["earlier"]["slug"], "emerson-huang-f6afead5") + self.assertEqual(facts[11]["target"]["slug"], "sean-blackwood-45e84c50") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/webmd_doctor/verify/verify_0.py b/sites/webmd_doctor/verify/verify_0.py new file mode 100644 index 00000000..cd89e104 --- /dev/null +++ b/sites/webmd_doctor/verify/verify_0.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Verify WebMD Doctor--0: Dermatologist search near Newark; Dr. Jonah Dimitriou: medical school + graduation year (read-only). + +Deterministic only: no LLM calls. Ground truth is hardcoded below, never in +tasks.jsonl, and cross-checked against the initial snapshot by ground_truth.py. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_read_only, + check_results_visited, + check_trajectory_identity, + check_visited_profile, + contains_institution, + contains_year, + fail_closed, + final_answer, + Judge, + load_run, + NEWARK, + parse_args, + resolve_snapshots, +) + + +TASK_ID = "WebMD Doctor--0" +SLUG = "jonah-dimitriou-c4ce067c" +SCHOOL = "Chesapeake Bay School of Medicine" +GRADUATION_YEAR = 2004 + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + check_results_visited(judge, trajectory, "visited_results_dermatologist_search", {"q": r"dermatolog", "loc": NEWARK}, {"sids": "1", "loc": NEWARK}) + check_visited_profile(judge, trajectory, SLUG) + judge.check("answer_has_medical_school", contains_institution(answer, SCHOOL), f"expected={SCHOOL!r}, answer={answer!r}") + judge.check("answer_has_graduation_year", contains_year(answer, GRADUATION_YEAR), f"expected={GRADUATION_YEAR!r}, answer={answer!r}") + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/webmd_doctor/verify/verify_1.py b/sites/webmd_doctor/verify/verify_1.py new file mode 100644 index 00000000..9e51b595 --- /dev/null +++ b/sites/webmd_doctor/verify/verify_1.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Verify WebMD Doctor--1: Dr. Julian Zamora (Cardiovascular Disease, Wilmington): NPI + languages (read-only). + +Deterministic only: no LLM calls. Ground truth is hardcoded below, never in +tasks.jsonl, and cross-checked against the initial snapshot by ground_truth.py. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_read_only, + check_trajectory_identity, + check_visited_profile, + contains_all, + contains_npi, + fail_closed, + final_answer, + Judge, + load_run, + parse_args, + resolve_snapshots, +) + + +TASK_ID = "WebMD Doctor--1" +SLUG = "julian-zamora-d412b77d" +NPI = "1438496704" +LANGUAGES = ("English", "Tagalog", "Portuguese") + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + check_visited_profile(judge, trajectory, SLUG) + judge.check("answer_has_npi", contains_npi(answer, NPI), f"expected={NPI!r}, answer={answer!r}") + judge.check("answer_lists_languages", contains_all(answer, LANGUAGES), f"expected={LANGUAGES!r}, answer={answer!r}") + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/webmd_doctor/verify/verify_10.py b/sites/webmd_doctor/verify/verify_10.py new file mode 100644 index 00000000..174ab6aa --- /dev/null +++ b/sites/webmd_doctor/verify/verify_10.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Verify WebMD Doctor--10: Psychiatrists near Newark accepting Medicaid rated 4+; the virtual-visit one: wait time + residency (read-only). + +Deterministic only: no LLM calls. Ground truth is hardcoded below, never in +tasks.jsonl, and cross-checked against the initial snapshot by ground_truth.py. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_read_only, + check_results_visited, + check_trajectory_identity, + check_visited_profile, + contains_institution, + contains_minutes, + fail_closed, + final_answer, + Judge, + load_run, + NEWARK, + parse_args, + resolve_snapshots, +) + + +TASK_ID = "WebMD Doctor--10" +SLUG = "veronica-nwachukwu-71051d38" +WAIT_MINUTES = 25 +RESIDENCY = "Rappahannock University Hospital" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + check_results_visited( + judge, trajectory, "visited_medicaid_rated_results", + {"q": r"psychiatr", "medicaid": True, "minrating": "4", "loc": NEWARK}, + {"sids": "7", "medicaid": True, "minrating": "4", "loc": NEWARK}, + {"q": r"psychiatr", "insuranceid": "7", "minrating": "4", "loc": NEWARK}, + ) + check_visited_profile(judge, trajectory, SLUG) + judge.check("answer_has_wait_minutes", contains_minutes(answer, WAIT_MINUTES), f"expected={WAIT_MINUTES!r} minutes, answer={answer!r}") + judge.check("answer_has_residency", contains_institution(answer, RESIDENCY), f"expected={RESIDENCY!r}, answer={answer!r}") + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/webmd_doctor/verify/verify_11.py b/sites/webmd_doctor/verify/verify_11.py new file mode 100644 index 00000000..038324ca --- /dev/null +++ b/sites/webmd_doctor/verify/verify_11.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Verify WebMD Doctor--11: Family Medicine within 10 miles sorted by Number of Ratings; second-highest: NPI + residency (read-only). + +Deterministic only: no LLM calls. Ground truth is hardcoded below, never in +tasks.jsonl, and cross-checked against the initial snapshot by ground_truth.py. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_read_only, + check_results_visited, + check_trajectory_identity, + check_visited_profile, + contains_institution, + contains_npi, + fail_closed, + final_answer, + Judge, + load_run, + NEWARK, + parse_args, + resolve_snapshots, +) + + +TASK_ID = "WebMD Doctor--11" +SLUG = "sean-blackwood-45e84c50" +NPI = "1472790926" +RESIDENCY = "Elk Neck Medical Center" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + check_results_visited(judge, trajectory, "visited_results_10mi_sorted_by_ratings", {"q": r"family", "d": "10", "sortby": "num_rating", "loc": NEWARK}, {"sids": "3", "d": "10", "sortby": "num_rating", "loc": NEWARK}) + check_visited_profile(judge, trajectory, SLUG) + judge.check("answer_has_npi", contains_npi(answer, NPI), f"expected={NPI!r}, answer={answer!r}") + judge.check("answer_has_residency", contains_institution(answer, RESIDENCY), f"expected={RESIDENCY!r}, answer={answer!r}") + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/webmd_doctor/verify/verify_12.py b/sites/webmd_doctor/verify/verify_12.py new file mode 100644 index 00000000..55bf7656 --- /dev/null +++ b/sites/webmd_doctor/verify/verify_12.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Verify WebMD Doctor--12: Specialty menu: Cardiovascular Disease > Pennsylvania > West Chester, 4+ stars; the only male: fellowship + year (read-only). + +Deterministic only: no LLM calls. Ground truth is hardcoded below, never in +tasks.jsonl, and cross-checked against the initial snapshot by ground_truth.py. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_paths_in_order, + check_read_only, + check_results_visited, + check_trajectory_identity, + check_visited_path, + check_visited_profile, + contains_institution, + contains_year, + fail_closed, + final_answer, + Judge, + load_run, + parse_args, + profile_path_pattern, + resolve_snapshots, +) + + +TASK_ID = "WebMD Doctor--12" +SLUG = "joseph-iyer-29b88273" +LANDING = "/providers/specialty/cardiovascular-disease" +STATE_PAGE = LANDING + "/pennsylvania" +CITY_PAGE = STATE_PAGE + "/west-chester" +FELLOWSHIP = "Allegheny Ridge Medical Center" +FELLOWSHIP_YEAR = 1987 + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + check_visited_path(judge, trajectory, "visited_specialty_landing_page", LANDING) + check_visited_path(judge, trajectory, "visited_state_page", STATE_PAGE) + check_visited_path(judge, trajectory, "visited_city_page", CITY_PAGE) + check_results_visited(judge, trajectory, "visited_city_page_rated_4_up", {"minrating": "4"}, path=CITY_PAGE) + check_visited_profile(judge, trajectory, SLUG) + check_paths_in_order(judge, trajectory, "workflow_in_order", [(LANDING, {}), (STATE_PAGE, {}), (CITY_PAGE, {}), (profile_path_pattern(SLUG), {})]) + judge.check("answer_has_fellowship", contains_institution(answer, FELLOWSHIP), f"expected={FELLOWSHIP!r}, answer={answer!r}") + judge.check("answer_has_fellowship_year", contains_year(answer, FELLOWSHIP_YEAR), f"expected={FELLOWSHIP_YEAR!r}, answer={answer!r}") + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/webmd_doctor/verify/verify_13.py b/sites/webmd_doctor/verify/verify_13.py new file mode 100644 index 00000000..396bc33f --- /dev/null +++ b/sites/webmd_doctor/verify/verify_13.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Verify WebMD Doctor--13: Dermatologists in Wilmington: Dr. Gregory Greenwood vs Dr. Emerson Huang, earlier medical-school graduate (read-only, both profiles). + +Deterministic only: no LLM calls. Ground truth is hardcoded below, never in +tasks.jsonl, and cross-checked against the initial snapshot by ground_truth.py. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_read_only, + check_trajectory_identity, + check_visited_profile, + contains_doctor_name, + contains_year, + fail_closed, + final_answer, + Judge, + load_run, + parse_args, + resolve_snapshots, +) + + +TASK_ID = "WebMD Doctor--13" +WINNER_SLUG = "emerson-huang-f6afead5" +WINNER_FIRST, WINNER_LAST = "Emerson", "Huang" +GRADUATION_YEAR = 1992 +OTHER_SLUG = "gregory-greenwood-34670192" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + check_visited_profile(judge, trajectory, WINNER_SLUG) + check_visited_profile(judge, trajectory, OTHER_SLUG) + judge.check("answer_names_earlier_graduate", contains_doctor_name(answer, WINNER_FIRST, WINNER_LAST), f"expected={WINNER_FIRST + ' ' + WINNER_LAST!r}, answer={answer!r}") + judge.check("answer_has_graduation_year", contains_year(answer, GRADUATION_YEAR), f"expected={GRADUATION_YEAR!r}, answer={answer!r}") + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/webmd_doctor/verify/verify_14.py b/sites/webmd_doctor/verify/verify_14.py new file mode 100644 index 00000000..a17dbdf6 --- /dev/null +++ b/sites/webmd_doctor/verify/verify_14.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Verify WebMD Doctor--14: Christina Creek Medical Center (Hospitals > Delaware): its two Psychiatrists, more recent board certification (read-only, both profiles). + +Deterministic only: no LLM calls. Ground truth is hardcoded below, never in +tasks.jsonl, and cross-checked against the initial snapshot by ground_truth.py. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_paths_in_order, + check_read_only, + check_trajectory_identity, + check_visited_path, + check_visited_profile, + contains_doctor_name, + contains_year, + fail_closed, + final_answer, + Judge, + load_run, + parse_args, + profile_path_pattern, + resolve_snapshots, +) + + +TASK_ID = "WebMD Doctor--14" +HOSPITALS_STATE_PATH = "/hospitals/delaware" +HOSPITAL_PATH = "/hospital/christina-creek-medical-center" +WINNER_SLUG = "arjun-bouchard-f3c85053" +WINNER_FIRST, WINNER_LAST = "Arjun", "Bouchard" +CERT_YEAR = 2004 +OTHER_SLUG = "colin-ellery-640b0a4a" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + check_visited_path(judge, trajectory, "visited_delaware_hospitals_page", HOSPITALS_STATE_PATH) + check_visited_path(judge, trajectory, "visited_hospital_page", HOSPITAL_PATH) + check_visited_profile(judge, trajectory, WINNER_SLUG) + check_visited_profile(judge, trajectory, OTHER_SLUG) + for label, slug in (("winner", WINNER_SLUG), ("other", OTHER_SLUG)): + check_paths_in_order(judge, trajectory, f"{label}_workflow_in_order", [(HOSPITALS_STATE_PATH, {}), (HOSPITAL_PATH, {}), (profile_path_pattern(slug), {})]) + judge.check("answer_names_more_recent_certification", contains_doctor_name(answer, WINNER_FIRST, WINNER_LAST), f"expected={WINNER_FIRST + ' ' + WINNER_LAST!r}, answer={answer!r}") + judge.check("answer_has_certification_year", contains_year(answer, CERT_YEAR), f"expected={CERT_YEAR!r}, answer={answer!r}") + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/webmd_doctor/verify/verify_15.py b/sites/webmd_doctor/verify/verify_15.py new file mode 100644 index 00000000..1de59ef8 --- /dev/null +++ b/sites/webmd_doctor/verify/verify_15.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Verify WebMD Doctor--15: Award Winning Hospitals > Patient's Choice recipients; the Orthopedic Surgery recipient in Media; practice website + Saturday hours (read-only). + +Deterministic only: no LLM calls. Ground truth is hardcoded below, never in +tasks.jsonl, and cross-checked against the initial snapshot by ground_truth.py. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_paths_in_order, + check_read_only, + check_results_visited, + check_trajectory_identity, + check_visited_path, + check_visited_profile, + contains_hours_window, + contains_url, + fail_closed, + final_answer, + Judge, + load_run, + parse_args, + profile_path_pattern, + resolve_snapshots, +) + + +TASK_ID = "WebMD Doctor--15" +SLUG = "linda-merriweather-378753c8" +AWARDS_PATH = "/choice-awards" +RECIPIENTS_PATH = "/choice-awards/awardrecipients" +PRACTICE_PATH = "/practice/rose-tree-orthopedics-sports-medicine" +WEBSITE = "https://www.rosetreeorthopedicssportsmedicine.example" +SAT_OPEN = "9:00 am" +SAT_CLOSE = "2:00 pm" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + check_visited_path(judge, trajectory, "visited_award_page", AWARDS_PATH) + check_results_visited(judge, trajectory, "visited_patients_choice_recipients_page", {"award-class": ("patient", "")}, path=RECIPIENTS_PATH) + check_visited_profile(judge, trajectory, SLUG) + check_visited_path(judge, trajectory, "visited_practice_page", PRACTICE_PATH) + check_paths_in_order(judge, trajectory, "workflow_in_order", [(AWARDS_PATH, {}), (RECIPIENTS_PATH, {"award-class": ("patient", "")}), (profile_path_pattern(SLUG), {}), (PRACTICE_PATH, {})]) + judge.check("answer_has_practice_website", contains_url(answer, WEBSITE), f"expected={WEBSITE!r}, answer={answer!r}") + judge.check("answer_has_saturday_hours", contains_hours_window(answer, SAT_OPEN, SAT_CLOSE), f"expected={SAT_OPEN!r}-{SAT_CLOSE!r}, answer={answer!r}") + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/webmd_doctor/verify/verify_16.py b/sites/webmd_doctor/verify/verify_16.py new file mode 100644 index 00000000..8ad5b01d --- /dev/null +++ b/sites/webmd_doctor/verify/verify_16.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Verify WebMD Doctor--16: Bob signs in, Pediatrician search near Newark, saves Dr. Anita Castellano, confirms in Saved Providers (stateful). + +Deterministic only: no LLM calls. Ground truth is hardcoded below, never in +tasks.jsonl, and cross-checked against the initial snapshot by ground_truth.py. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_exact_delta, + check_paths_in_order, + check_results_visited, + check_signed_in_as, + check_tables_unchanged, + check_trajectory_identity, + check_visited_path, + check_visited_profile, + contains_any, + fail_closed, + final_answer, + Judge, + load_run, + NEWARK, + parse_args, + profile_path_pattern, + resolve_snapshots, + saved_delta, +) + + +TASK_ID = "WebMD Doctor--16" +SLUG = "anita-castellano-14da1f29" +DOCTOR_ID = 163 +EMAIL = "bob.c@test.com" +USER_ID = 2 +SAVED_PATH = "/account/saved" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + check_signed_in_as(judge, trajectory, EMAIL) + check_results_visited(judge, trajectory, "visited_results_pediatrician_search", {"q": r"pediatric", "loc": NEWARK}, {"sids": "9", "loc": NEWARK}) + check_visited_profile(judge, trajectory, SLUG) + check_visited_path(judge, trajectory, "visited_saved_providers_page", SAVED_PATH) + check_paths_in_order(judge, trajectory, "workflow_in_order", [("/login", {}), ("/results", {}), (profile_path_pattern(SLUG), {}), (SAVED_PATH, {})]) + judge.check("answer_confirms_saved", contains_any(answer, ("Castellano", "saved", "appears", "listed")), f"answer={answer!r}") + added, removed = saved_delta(initial_db, after_db, USER_ID) + judge.check("new_saved_row_belongs_to_bob", added == {DOCTOR_ID} and not removed, f"expected added=[{DOCTOR_ID}] removed=[]; observed added={sorted(added)!r} removed={sorted(removed)!r}") + check_exact_delta(judge, initial_db, after_db, "saved_providers", added=1) + check_tables_unchanged(judge, initial_db, after_db, ("users", "appointment_requests", "user_reviews")) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/webmd_doctor/verify/verify_17.py b/sites/webmd_doctor/verify/verify_17.py new file mode 100644 index 00000000..3dae8e9c --- /dev/null +++ b/sites/webmd_doctor/verify/verify_17.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Verify WebMD Doctor--17: Carol signs in, requests a New Patient appointment with Dr. Sarah Keller at the named office on Mon Sep 14 10:30 AM; reports the reference (stateful). + +Deterministic only: no LLM calls. Ground truth is hardcoded below, never in +tasks.jsonl, and cross-checked against the initial snapshot by ground_truth.py. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_exact_delta, + check_paths_in_order, + check_signed_in_as, + check_tables_unchanged, + check_trajectory_identity, + check_visited_path, + check_visited_profile, + contains_reference, + extract_references, + fail_closed, + final_answer, + Judge, + load_run, + new_table_rows, + parse_args, + profile_path_pattern, + resolve_snapshots, + rows_where, +) + + +TASK_ID = "WebMD Doctor--17" +SLUG = "sarah-keller-f85bed81" +DOCTOR_ID = 22 +EMAIL = "carol.d@test.com" +USER_ID = 3 +LOCATION_ID = 32 # Riverfront Heart & Vascular - Wellness Center (the non-primary office) +PATIENT_TYPE = "New Patient" +SLOT_DATE = "2026-09-14" +SLOT_TIME = "10:30 AM" +BOOKING_PATH = f"/doctor/{SLUG}/bookappointment" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + check_signed_in_as(judge, trajectory, EMAIL) + check_visited_profile(judge, trajectory, SLUG) + check_visited_path(judge, trajectory, "visited_booking_page", BOOKING_PATH) + check_paths_in_order(judge, trajectory, "workflow_in_order", [("/login", {}), (profile_path_pattern(SLUG), {}), (BOOKING_PATH, {})]) + judge.check("initial_has_no_carol_request_for_target", not rows_where(initial_db, "appointment_requests", user_id=USER_ID, doctor_id=DOCTOR_ID), f"user_id={USER_ID}, doctor_id={DOCTOR_ID}") + new_rows = new_table_rows(initial_db, after_db, "appointment_requests") + judge.check("exactly_one_new_request", len(new_rows) == 1, f"new_rows={new_rows!r}") + row = new_rows[0] if len(new_rows) == 1 else {} + judge.check("new_request_belongs_to_carol", row.get("user_id") == USER_ID, f"expected_user_id={USER_ID}, row={row!r}") + judge.check("new_request_is_for_target", row.get("doctor_id") == DOCTOR_ID, f"expected_doctor_id={DOCTOR_ID}, row={row!r}") + judge.check("new_request_at_named_office", row.get("location_id") == LOCATION_ID, f"expected_location_id={LOCATION_ID}, row={row!r}") + judge.check("new_request_is_new_patient", row.get("patient_type") == PATIENT_TYPE, f"expected={PATIENT_TYPE!r}, row={row!r}") + judge.check( + "new_request_slot_matches", + str(row.get("slot_date") or "").startswith(SLOT_DATE) and str(row.get("slot_time") or "").strip().upper() == SLOT_TIME, + f"expected={SLOT_DATE} {SLOT_TIME}, row={row!r}", + ) + reference = str(row.get("reference") or "") + judge.check("answer_has_matching_reference", bool(reference) and contains_reference(answer, reference), f"row_reference={reference!r}, answer={answer!r}") + judge.check("answer_has_no_other_reference", extract_references(answer) <= ({reference} if reference else set()), f"answer_references={sorted(extract_references(answer))!r}") + check_exact_delta(judge, initial_db, after_db, "appointment_requests", added=1) + check_tables_unchanged(judge, initial_db, after_db, ("users", "saved_providers", "user_reviews")) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/webmd_doctor/verify/verify_18.py b/sites/webmd_doctor/verify/verify_18.py new file mode 100644 index 00000000..f449a272 --- /dev/null +++ b/sites/webmd_doctor/verify/verify_18.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Verify WebMD Doctor--18: David signs in, leaves a 4-star review with the quoted text on Dr. Tariq Huang, confirms Pending review (stateful). + +Deterministic only: no LLM calls. Ground truth is hardcoded below, never in +tasks.jsonl, and cross-checked against the initial snapshot by ground_truth.py. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_ended_on_profile, + check_exact_delta, + check_paths_in_order, + check_signed_in_as, + check_tables_unchanged, + check_trajectory_identity, + check_visited_profile, + contains_any, + fail_closed, + final_answer, + Judge, + load_run, + new_table_rows, + parse_args, + profile_path_pattern, + resolve_snapshots, + review_text_matches, + rows_where, +) + + +TASK_ID = "WebMD Doctor--18" +SLUG = "tariq-huang-c8120504" +DOCTOR_ID = 18 +EMAIL = "david.k@test.com" +USER_ID = 4 +RATING = 4 +TEXT = "Short wait and a clear explanation of my treatment options." +STATUS = "Pending review" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + check_signed_in_as(judge, trajectory, EMAIL) + check_visited_profile(judge, trajectory, SLUG) + check_paths_in_order(judge, trajectory, "workflow_in_order", [("/login", {}), (profile_path_pattern(SLUG), {})]) + check_ended_on_profile(judge, trajectory, SLUG) + judge.check("initial_has_no_david_review_for_target", not rows_where(initial_db, "user_reviews", user_id=USER_ID, doctor_id=DOCTOR_ID), f"user_id={USER_ID}, doctor_id={DOCTOR_ID}") + new_rows = new_table_rows(initial_db, after_db, "user_reviews") + judge.check("exactly_one_new_review", len(new_rows) == 1, f"new_rows={new_rows!r}") + row = new_rows[0] if len(new_rows) == 1 else {} + judge.check("new_review_belongs_to_david", row.get("user_id") == USER_ID, f"expected_user_id={USER_ID}, row={row!r}") + judge.check("new_review_is_for_target", row.get("doctor_id") == DOCTOR_ID, f"expected_doctor_id={DOCTOR_ID}, row={row!r}") + judge.check("new_review_rating_is_4", row.get("rating") == RATING, f"expected={RATING}, row={row!r}") + judge.check("new_review_text_matches", review_text_matches(row.get("text"), TEXT), f"expected={TEXT!r}, row_text={row.get('text')!r}") + judge.check("new_review_status_pending", str(row.get("status") or "").casefold() == STATUS.casefold(), f"expected={STATUS!r}, row={row!r}") + judge.check("answer_confirms_pending", contains_any(answer, ("pending",)), f"answer={answer!r}") + check_exact_delta(judge, initial_db, after_db, "user_reviews", added=1) + check_tables_unchanged(judge, initial_db, after_db, ("users", "saved_providers", "appointment_requests")) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/webmd_doctor/verify/verify_19.py b/sites/webmd_doctor/verify/verify_19.py new file mode 100644 index 00000000..1efee7b9 --- /dev/null +++ b/sites/webmd_doctor/verify/verify_19.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Verify WebMD Doctor--19: Register a new account; Neurologists near Newark with virtual visits; save Dr. Monica Carrington; report her NPI (stateful). + +Deterministic only: no LLM calls. Ground truth is hardcoded below, never in +tasks.jsonl, and cross-checked against the initial snapshot by ground_truth.py. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_exact_delta, + check_paths_in_order, + check_results_visited, + check_tables_unchanged, + check_trajectory_identity, + check_visited_path, + check_visited_profile, + contains_npi, + fail_closed, + final_answer, + Judge, + load_run, + new_table_rows, + new_user_rows, + NEWARK, + normalize_text, + parse_args, + profile_path_pattern, + resolve_snapshots, + signup_email, + user_emails, +) + + +TASK_ID = "WebMD Doctor--19" +SLUG = "monica-carrington-62f5d8a2" +DOCTOR_ID = 68 +NPI = "1279956602" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + check_visited_path(judge, trajectory, "visited_signup_page", "/signup") + check_results_visited( + judge, trajectory, "visited_virtual_neurologist_results", + {"q": r"neurolog", "isvirtualvisit": True, "loc": NEWARK}, + {"sids": "4", "isvirtualvisit": True, "loc": NEWARK}, + {"q": r"(?=.*neurolog)(?=.*(?:virtual|telehealth|video visit))", "loc": NEWARK}, + ) + check_visited_profile(judge, trajectory, SLUG) + check_paths_in_order(judge, trajectory, "workflow_in_order", [("/signup", {}), ("/results", {}), (profile_path_pattern(SLUG), {})]) + typed_email = signup_email(trajectory) + new_users = new_user_rows(initial_db, after_db) + judge.check("exactly_one_new_user", len(new_users) == 1, f"new_users={[row.get('email') for row in new_users]!r}") + user = new_users[0] if len(new_users) == 1 else {} + judge.check("new_user_email_matches_signup_input", bool(typed_email) and normalize_text(user.get("email")) == typed_email, f"typed_on_signup={typed_email!r}, new_user_email={user.get('email')!r}") + judge.check("new_user_is_not_a_seed_account", bool(user) and normalize_text(user.get("email")) not in user_emails(initial_db), f"new_user_email={user.get('email')!r}") + check_exact_delta(judge, initial_db, after_db, "users", added=1) + new_saved = new_table_rows(initial_db, after_db, "saved_providers") + judge.check("exactly_one_new_saved_row", len(new_saved) == 1, f"new_saved={new_saved!r}") + saved = new_saved[0] if len(new_saved) == 1 else {} + judge.check("new_saved_row_belongs_to_new_user", bool(user) and saved.get("user_id") == user.get("id"), f"new_user_id={user.get('id')!r}, row={saved!r}") + judge.check("new_saved_row_is_for_target", saved.get("doctor_id") == DOCTOR_ID, f"expected_doctor_id={DOCTOR_ID}, row={saved!r}") + check_exact_delta(judge, initial_db, after_db, "saved_providers", added=1) + judge.check("answer_has_npi", contains_npi(answer, NPI), f"expected={NPI!r}, answer={answer!r}") + check_tables_unchanged(judge, initial_db, after_db, ("appointment_requests", "user_reviews")) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/webmd_doctor/verify/verify_2.py b/sites/webmd_doctor/verify/verify_2.py new file mode 100644 index 00000000..81150546 --- /dev/null +++ b/sites/webmd_doctor/verify/verify_2.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Verify WebMD Doctor--2: Family Medicine search near Newark; Dr. Ruth Thackeray: primary office phone + Saturday hours (read-only). + +Deterministic only: no LLM calls. Ground truth is hardcoded below, never in +tasks.jsonl, and cross-checked against the initial snapshot by ground_truth.py. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_read_only, + check_results_visited, + check_trajectory_identity, + check_visited_profile, + contains_hours_window, + contains_phone, + fail_closed, + final_answer, + Judge, + load_run, + NEWARK, + parse_args, + resolve_snapshots, +) + + +TASK_ID = "WebMD Doctor--2" +SLUG = "ruth-thackeray-45234b97" +PHONE = "(302) 555-1542" +SAT_OPEN = "8:00 am" +SAT_CLOSE = "1:00 pm" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + check_results_visited(judge, trajectory, "visited_results_family_medicine_search", {"q": r"family", "loc": NEWARK}, {"sids": "3", "loc": NEWARK}) + check_visited_profile(judge, trajectory, SLUG) + judge.check("answer_has_primary_office_phone", contains_phone(answer, PHONE), f"expected={PHONE!r}, answer={answer!r}") + judge.check("answer_has_saturday_hours", contains_hours_window(answer, SAT_OPEN, SAT_CLOSE), f"expected={SAT_OPEN!r}-{SAT_CLOSE!r}, answer={answer!r}") + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/webmd_doctor/verify/verify_3.py b/sites/webmd_doctor/verify/verify_3.py new file mode 100644 index 00000000..1c24cc00 --- /dev/null +++ b/sites/webmd_doctor/verify/verify_3.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Verify WebMD Doctor--3: Dr. Mateo Alvarado (Neurology, West Chester): the non-primary office name + street (read-only). + +Deterministic only: no LLM calls. Ground truth is hardcoded below, never in +tasks.jsonl, and cross-checked against the initial snapshot by ground_truth.py. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_read_only, + check_trajectory_identity, + check_visited_profile, + contains_institution, + contains_street, + fail_closed, + final_answer, + Judge, + load_run, + parse_args, + resolve_snapshots, +) + + +TASK_ID = "WebMD Doctor--3" +SLUG = "mateo-alvarado-f727bd61" +OFFICE_NAME = "Providence Road Medical Group - Professional Plaza" +STREET = "3543 Baltimore Pike" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + check_visited_profile(judge, trajectory, SLUG) + judge.check("answer_has_other_office_name", contains_institution(answer, OFFICE_NAME), f"expected={OFFICE_NAME!r}, answer={answer!r}") + judge.check("answer_has_other_office_street", contains_street(answer, STREET), f"expected={STREET!r}, answer={answer!r}") + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/webmd_doctor/verify/verify_4.py b/sites/webmd_doctor/verify/verify_4.py new file mode 100644 index 00000000..e9cbb9d5 --- /dev/null +++ b/sites/webmd_doctor/verify/verify_4.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Verify WebMD Doctor--4: Dr. Charles Villanueva (Orthopedic Surgery, Elkton): board + certification year + residency (read-only). + +Deterministic only: no LLM calls. Ground truth is hardcoded below, never in +tasks.jsonl, and cross-checked against the initial snapshot by ground_truth.py. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_read_only, + check_trajectory_identity, + check_visited_profile, + contains_any, + contains_institution, + contains_year, + fail_closed, + final_answer, + Judge, + load_run, + parse_args, + resolve_snapshots, +) + + +TASK_ID = "WebMD Doctor--4" +SLUG = "charles-villanueva-13e969b2" +BOARDS = ("American Board of Orthopaedic Surgery", "American Board of Orthopedic Surgery") +CERT_YEAR = 2014 +RESIDENCY = "Blue Ridge Regional Medical Center" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + check_visited_profile(judge, trajectory, SLUG) + judge.check("answer_has_certifying_board", contains_any(answer, BOARDS), f"expected={BOARDS[0]!r}, answer={answer!r}") + judge.check("answer_has_certification_year", contains_year(answer, CERT_YEAR), f"expected={CERT_YEAR!r}, answer={answer!r}") + judge.check("answer_has_residency", contains_institution(answer, RESIDENCY), f"expected={RESIDENCY!r}, answer={answer!r}") + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/webmd_doctor/verify/verify_5.py b/sites/webmd_doctor/verify/verify_5.py new file mode 100644 index 00000000..61161fbc --- /dev/null +++ b/sites/webmd_doctor/verify/verify_5.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Verify WebMD Doctor--5: Gastroenterologist search near Newark; Dr. Caroline Danforth: the More-Than-Most condition + first Top-20 entry (read-only). + +Deterministic only: no LLM calls. Ground truth is hardcoded below, never in +tasks.jsonl, and cross-checked against the initial snapshot by ground_truth.py. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_read_only, + check_results_visited, + check_trajectory_identity, + check_visited_profile, + contains_condition, + fail_closed, + final_answer, + Judge, + load_run, + NEWARK, + parse_args, + resolve_snapshots, +) + + +TASK_ID = "WebMD Doctor--5" +SLUG = "caroline-danforth-7093653d" +MORE_THAN_MOST = "Acid Reflux (GERD)" +FIRST_TOP20 = "Anemia" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + check_results_visited(judge, trajectory, "visited_results_gastroenterologist_search", {"q": r"gastroenterolog", "loc": NEWARK}, {"sids": "6", "loc": NEWARK}) + check_visited_profile(judge, trajectory, SLUG) + judge.check("answer_has_more_than_most_condition", contains_condition(answer, MORE_THAN_MOST), f"expected={MORE_THAN_MOST!r}, answer={answer!r}") + judge.check("answer_has_first_top20_condition", contains_condition(answer, FIRST_TOP20), f"expected={FIRST_TOP20!r}, answer={answer!r}") + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/webmd_doctor/verify/verify_6.py b/sites/webmd_doctor/verify/verify_6.py new file mode 100644 index 00000000..bc81af1b --- /dev/null +++ b/sites/webmd_doctor/verify/verify_6.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Verify WebMD Doctor--6: Dr. Fatima Jensen (Psychiatry, Media): oldest review date + its star rating, second review page required (read-only). + +Deterministic only: no LLM calls. Ground truth is hardcoded below, never in +tasks.jsonl, and cross-checked against the initial snapshot by ground_truth.py. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_read_only, + check_trajectory_identity, + check_visited_profile, + contains_review_date, + contains_star_rating, + fail_closed, + final_answer, + Judge, + load_run, + parse_args, + profile_visited_with, + resolve_snapshots, +) + + +TASK_ID = "WebMD Doctor--6" +import datetime as _dt + +SLUG = "fatima-jensen-e5a26d53" +OLDEST_DATE = _dt.date(2022, 11, 2) +OLDEST_RATING = 4 + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + check_visited_profile(judge, trajectory, SLUG) + judge.check("visited_reviews_page_2", profile_visited_with(trajectory, SLUG, rpage="2"), f"required=/doctor/{SLUG}-overview?rpage=2") + judge.check("answer_has_oldest_review_date", contains_review_date(answer, OLDEST_DATE), f"expected={OLDEST_DATE.isoformat()!r}, answer={answer!r}") + judge.check("answer_has_oldest_review_rating", contains_star_rating(answer, OLDEST_RATING), f"expected={OLDEST_RATING!r} stars, answer={answer!r}") + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/webmd_doctor/verify/verify_7.py b/sites/webmd_doctor/verify/verify_7.py new file mode 100644 index 00000000..c7ea98af --- /dev/null +++ b/sites/webmd_doctor/verify/verify_7.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Verify WebMD Doctor--7: Dr. Lillian Acosta (OBGYN, Salem): criterion with most needs-improvement votes + average wait (read-only). + +Deterministic only: no LLM calls. Ground truth is hardcoded below, never in +tasks.jsonl, and cross-checked against the initial snapshot by ground_truth.py. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_read_only, + check_trajectory_identity, + check_visited_profile, + contains_any, + contains_minutes, + fail_closed, + final_answer, + Judge, + load_run, + parse_args, + resolve_snapshots, +) + + +TASK_ID = "WebMD Doctor--7" +SLUG = "lillian-acosta-4a89e15e" +CRITERION = ("Staff was courteous", "Staff courteous") +WAIT_MINUTES = 15 + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + check_visited_profile(judge, trajectory, SLUG) + judge.check("answer_has_worst_criterion", contains_any(answer, CRITERION), f"expected={CRITERION[0]!r}, answer={answer!r}") + judge.check("answer_has_wait_minutes", contains_minutes(answer, WAIT_MINUTES), f"expected={WAIT_MINUTES!r} minutes, answer={answer!r}") + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/webmd_doctor/verify/verify_8.py b/sites/webmd_doctor/verify/verify_8.py new file mode 100644 index 00000000..2546d5aa --- /dev/null +++ b/sites/webmd_doctor/verify/verify_8.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Verify WebMD Doctor--8: Alice signs in; the only saved Dermatologist: residency, then remove it from Saved Providers (stateful). + +Deterministic only: no LLM calls. Ground truth is hardcoded below, never in +tasks.jsonl, and cross-checked against the initial snapshot by ground_truth.py. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_exact_delta, + check_paths_in_order, + check_signed_in_as, + check_tables_unchanged, + check_trajectory_identity, + check_visited_path, + check_visited_profile, + contains_institution, + fail_closed, + final_answer, + Judge, + load_run, + parse_args, + profile_path_pattern, + resolve_snapshots, + saved_delta, + saved_doctor_ids, +) + + +TASK_ID = "WebMD Doctor--8" +SLUG = "adrian-navarro-b338ac81" +DOCTOR_ID = 5 +EMAIL = "alice.j@test.com" +USER_ID = 1 +SAVED_PATH = "/account/saved" +RESIDENCY = "Piedmont Atlantic Hospital" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + check_signed_in_as(judge, trajectory, EMAIL) + check_visited_path(judge, trajectory, "visited_saved_providers_page", SAVED_PATH) + check_visited_profile(judge, trajectory, SLUG) + check_paths_in_order(judge, trajectory, "workflow_in_order", [("/login", {}), (SAVED_PATH, {}), (profile_path_pattern(SLUG), {})]) + judge.check("initial_alice_has_target_saved", DOCTOR_ID in saved_doctor_ids(initial_db, USER_ID), f"initial_saved={sorted(saved_doctor_ids(initial_db, USER_ID))!r}") + judge.check("answer_has_residency", contains_institution(answer, RESIDENCY), f"expected={RESIDENCY!r}, answer={answer!r}") + added, removed = saved_delta(initial_db, after_db, USER_ID) + judge.check("removed_target_only", removed == {DOCTOR_ID} and not added, f"expected removed={{{DOCTOR_ID}}} added=set(); observed removed={sorted(removed)!r} added={sorted(added)!r}") + check_exact_delta(judge, initial_db, after_db, "saved_providers", removed=1) + check_tables_unchanged(judge, initial_db, after_db, ("users", "appointment_requests", "user_reviews")) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/webmd_doctor/verify/verify_9.py b/sites/webmd_doctor/verify/verify_9.py new file mode 100644 index 00000000..61870b44 --- /dev/null +++ b/sites/webmd_doctor/verify/verify_9.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Verify WebMD Doctor--9: Female Dermatologists near Newark accepting new patients + named insurer; the under-5-years doctor: school + certification year (read-only). + +Deterministic only: no LLM calls. Ground truth is hardcoded below, never in +tasks.jsonl, and cross-checked against the initial snapshot by ground_truth.py. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_read_only, + check_results_visited, + check_trajectory_identity, + check_visited_profile, + contains_institution, + contains_year, + fail_closed, + final_answer, + Judge, + load_run, + NEWARK, + parse_args, + resolve_snapshots, +) + + +TASK_ID = "WebMD Doctor--9" +SLUG = "nicole-dubois-243c6e66" +SCHOOL = "Tuckahoe College of Osteopathic Medicine" +CERT_YEAR = 2024 +FILTERS = {"gender": "f", "newpatient": True, "loc": NEWARK} + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + check_results_visited( + judge, trajectory, "visited_filtered_results", + {"q": r"dermatolog", "insuranceid": "4", **FILTERS}, + {"sids": "1", "insuranceid": "4", **FILTERS}, + {"q": r"(?=.*dermatolog)(?=.*blue cross)", **FILTERS}, + ) + check_visited_profile(judge, trajectory, SLUG) + judge.check("answer_has_medical_school", contains_institution(answer, SCHOOL), f"expected={SCHOOL!r}, answer={answer!r}") + judge.check("answer_has_certification_year", contains_year(answer, CERT_YEAR), f"expected={CERT_YEAR!r}, answer={answer!r}") + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/webmd_doctor/verify/verify_lib.py b/sites/webmd_doctor/verify/verify_lib.py new file mode 100644 index 00000000..a909a53a --- /dev/null +++ b/sites/webmd_doctor/verify/verify_lib.py @@ -0,0 +1,940 @@ +#!/usr/bin/env python3 +"""Shared deterministic helpers for WebMD Doctor task verifiers. + +Each verifier consumes an agent run directory plus before/after SQLite snapshots +and emits ``{task_id, pass, reason, evidence[]}`` with exit code 0/1. + +No helper in this module calls an LLM; a verdict never depends on a key or a +model. Ground truth lives only inside the per-task ``verify_N.py`` files (and is +cross-checked against the initial snapshot by ``ground_truth.py``). +""" +from __future__ import annotations + +import argparse +import atexit +import datetime as _dt +import hashlib +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 + +from PIL import Image + + +SITE = "webmd_doctor" +DEFAULT_CONTAINER = os.environ.get("WH_CONTAINER", "wh-review") + +# The four runtime tables. A read-only task must leave every one row-identical. +READ_ONLY_TABLES = ("users", "saved_providers", "appointment_requests", "user_reviews") + +# Sentinel for the "near Newark, DE 19711" location rule (see ``_loc_is_newark``). +NEWARK = "__newark__" + + +# --------------------------------------------------------------------------- # +# CLI / run loading +# --------------------------------------------------------------------------- # +@dataclass(frozen=True) +class VerifyArgs: + run_dir: str + initial_db: str | None + after_db: str | None + container: str + no_llm: bool + + +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=True) + 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=True, + ) + + +def load_run(run_dir: str | os.PathLike[str]) -> dict[str, Any]: + path = Path(run_dir) / "trajectory.json" + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError("trajectory.json must contain a JSON object") + data["_run_dir"] = str(Path(run_dir).resolve()) + return data + + +def final_answer(trajectory: dict[str, Any]) -> str: + return str(trajectory.get("final_answer") or "").strip() + + +def final_url(trajectory: dict[str, Any]) -> str: + direct = trajectory.get("final_url") + if direct: + return str(direct) + for step in reversed(trajectory.get("steps") or []): + if isinstance(step, dict) and step.get("url"): + return str(step["url"]) + return "" + + +def trajectory_urls(trajectory: dict[str, Any]) -> list[str]: + """Return every browser URL recorded by supported trajectory producers.""" + urls: list[str] = [] + 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", "url_before", "url_after"): + value = step.get(key) + if value: + urls.append(str(value)) + if trajectory.get("final_url"): + urls.append(str(trajectory["final_url"])) + return urls + + +def normalized_url_path(url: str) -> str: + path = urlparse(str(url or "")).path or "/" + return path.rstrip("/") or "/" + + +def is_site_url(url: str) -> bool: + """Accept HTTP(S) URLs on a loopback host while allowing any port. + + Runs hit the alt-port container (41024) while tasks.jsonl says 40024, so + the port is deliberately not checked here. + """ + parsed = urlparse(str(url or "")) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + return False + hostname = parsed.hostname.casefold() + if hostname == "localhost": + return True + try: + return ipaddress.ip_address(hostname).is_loopback + except ValueError: + return False + + +def site_urls(trajectory: dict[str, Any]) -> list[str]: + return [url for url in trajectory_urls(trajectory) if is_site_url(url)] + + +def _path_matches(url: str, expected: str | re.Pattern[str]) -> bool: + path = normalized_url_path(url) + if isinstance(expected, re.Pattern): + return expected.fullmatch(path) is not None + return path == normalized_url_path(expected) + + +def navigated_to_path(trajectory: dict[str, Any], expected_path: str | re.Pattern[str]) -> bool: + """Require an exact mirror path (or a full-path regex) on a loopback origin.""" + return any(_path_matches(url, expected_path) for url in site_urls(trajectory)) + + +def final_url_is_path(trajectory: dict[str, Any], expected_path: str | re.Pattern[str]) -> bool: + observed_url = final_url(trajectory) + return is_site_url(observed_url) and _path_matches(observed_url, expected_path) + + +def trajectory_task_matches(trajectory: dict[str, Any], task_id: str) -> bool: + return str(trajectory.get("task_id") or "").strip() == task_id + + +def trajectory_input_texts(trajectory: dict[str, Any], on_path: str | None = None) -> list[str]: + """Typed texts, optionally only from steps whose (before-action) URL path is ``on_path``.""" + values: list[str] = [] + for step in trajectory.get("steps") or []: + if not isinstance(step, dict) or normalize_text(step.get("action")) != "input": + continue + if on_path is not None and normalized_url_path(str(step.get("url") or "")) != normalized_url_path(on_path): + continue + params = step.get("params") + if isinstance(params, dict) and params.get("text") is not None: + values.append(str(params["text"])) + return values + + +_EMAIL_RE = re.compile(r"[^@\s]+@[^@\s]+\.[^@\s]+") + + +def trajectory_last_email(trajectory: dict[str, Any], on_path: str | None = None) -> str: + emails = [ + normalize_text(value) + for value in trajectory_input_texts(trajectory, on_path) + if _EMAIL_RE.fullmatch(value.strip()) + ] + return emails[-1] if emails else "" + + +def signup_email(trajectory: dict[str, Any]) -> str: + """The last e-mail typed while the browser was on ``/signup``.""" + return trajectory_last_email(trajectory, on_path="/signup") + + +# --------------------------------------------------------------------------- # +# Profile / results gates +# --------------------------------------------------------------------------- # +def profile_path_pattern(slug: str) -> re.Pattern[str]: + """``/doctor/-overview`` plus the three 301 tab aliases. + + ``/doctor//bookappointment``, ``/save`` and ``/review`` are not profile visits. + """ + return re.compile(rf"/doctor/{re.escape(slug)}-(?:overview|locations|reviews|insurance)") + + +def profile_visited(trajectory: dict[str, Any], slug: str) -> bool: + return navigated_to_path(trajectory, profile_path_pattern(slug)) + + +def profile_visited_with(trajectory: dict[str, Any], slug: str, **params: Any) -> bool: + return results_visited(trajectory, path=profile_path_pattern(slug), **params) + + +BOOL_PARAMS = {"newpatient", "medicaid", "medicare", "isvirtualvisit"} +_TRUE_VALUES = {"true", "1", "yes", "on"} + + +def _loc_is_newark(value: str) -> bool: + text = normalize_text(value) + if "19711" in text: + return True + return re.fullmatch(r"newark(?:,?\s*(?:de|delaware))?(?:,?\s*(?:usa|us))?", text) is not None + + +def _param_matches(query: dict[str, list[str]], key: str, expected: Any) -> bool: + if isinstance(expected, (tuple, list, set, frozenset)): + return any(_param_matches(query, key, alt) for alt in expected) + values = [value for value in (query.get(key) or []) if str(value).strip()] + if key == "loc": + if expected == NEWARK: + # The site defaults to Newark, DE 19711 when no location is given; an explicit + # other city (or a typed city= parameter) must resolve to Newark to count. + typed = values + [value for value in (query.get("city") or []) if str(value).strip()] + zips = [value for value in (query.get("zc") or []) if str(value).strip()] + if zips and not any("19711" in value for value in zips): + return False + return not typed or all(_loc_is_newark(value) for value in typed) + return any(re.search(str(expected), normalize_text(value)) for value in values) + if key == "q": + pattern = expected.pattern if isinstance(expected, re.Pattern) else str(expected) + return any(re.search(pattern, normalize_text(value)) for value in values) + if key in BOOL_PARAMS: + if expected in (True, "true", "1"): + return any(str(value).strip().lower() in _TRUE_VALUES for value in values) + return not any(str(value).strip().lower() in _TRUE_VALUES for value in values) + if expected == "": + return not values # the parameter must be absent (or blank) + if isinstance(expected, re.Pattern): + return any(expected.fullmatch(normalize_text(value)) for value in values) + return any(normalize_text(expected) == normalize_text(value) for value in values) + + +def results_visited( + trajectory: dict[str, Any], path: str | re.Pattern[str] = "/results", **params: Any +) -> bool: + """Some visit of ``path`` carries every requested query parameter. + + ``q`` is a regex searched in the normalized value; ``loc=NEWARK`` applies the + Newark rule; boolean facets accept ``true/1/yes/on``; everything else is an + exact normalized match. A value may be a tuple of alternatives. + """ + for url in site_urls(trajectory): + if not _path_matches(url, path): + continue + query = parse_qs(urlparse(url).query, keep_blank_values=True) + if all(_param_matches(query, key, expected) for key, expected in params.items()): + return True + return False + + +def _describe_params(params: dict[str, Any]) -> str: + pieces = [] + for key, value in params.items(): + if isinstance(value, re.Pattern): + value = f"/{value.pattern}/" + pieces.append(f"{key}~{value!r}") + return "&".join(pieces) or "(any)" + + +# --------------------------------------------------------------------------- # +# Text normalization and answer matchers +# --------------------------------------------------------------------------- # +DASH = r"[-‐‑‒–—−]" + + +def normalize_text(value: Any) -> str: + text = unicodedata.normalize("NFKC", str(value or "")) + text = text.replace("’", "'").replace("‘", "'").replace("“", '"').replace("”", '"') + text = re.sub(DASH, "-", text) + text = text.replace("&", " and ") + return re.sub(r"\s+", " ", text).strip().casefold() + + +def _match_is_affirmative(text: str, match: re.Match[str]) -> bool: + before = re.split(r"[.!?;:\n]+|\b(?:but|however|instead)\b", text[:match.start()], flags=re.I)[-1] + after = text[match.end():] + return not re.search(r"\b(?:not|no|never|without|wrong|incorrect|isn't|wasn't|isnt|wasnt)\b", before, re.I) and not re.match( + r"\s*(?:is|was|are|were)?\s*(?:not|wrong|incorrect)\b", after, re.I + ) + + +def _affirmative_search(pattern: str, text: str, flags: int = 0) -> bool: + return any(_match_is_affirmative(text, match) for match in re.finditer(pattern, text, flags)) + + +def _phrase_pattern(phrase: str) -> str: + tokens = re.findall(r"[a-z0-9]+", normalize_text(phrase)) + if not tokens: + return r"(?!x)x" + return r"(? bool: + """Whole-token phrase match: punctuation, dash style, ``&``/``and`` and case are ignored.""" + return _affirmative_search(_phrase_pattern(phrase), normalize_text(text)) + + +def contains_all(text: Any, expected: Iterable[str]) -> bool: + return all(contains_phrase(text, value) for value in expected) + + +def contains_any(text: Any, expected: Iterable[str]) -> bool: + return any(contains_phrase(text, value) for value in expected) + + +contains_institution = contains_phrase +contains_criterion = contains_phrase + + +def contains_condition(text: Any, label: str) -> bool: + """A condition label; a parenthesised abbreviation (``Acid Reflux (GERD)``) may stand alone.""" + alternatives = [label] + match = re.fullmatch(r"\s*(.+?)\s*\((.+?)\)\s*", label) + if match: + alternatives.extend([match.group(1), match.group(2)]) + return contains_any(text, alternatives) + + +def contains_doctor_name(text: Any, first_name: str, last_name: str) -> bool: + """Both name tokens present (order-free); a bare surname is not enough.""" + return contains_phrase(text, first_name) and contains_phrase(text, last_name) + + +def contains_year(text: Any, year: int) -> bool: + raw = unicodedata.normalize("NFKC", str(text or "")) + return _affirmative_search(rf"(? str: + return re.sub(r"\D", "", str(value or "")) + + +def contains_npi(text: Any, npi: str) -> bool: + """The 10-digit NPI as one token (single spaces or dashes between digits tolerated).""" + raw = unicodedata.normalize("NFKC", str(text or "")) + expected = digits_only(npi) + for match in re.finditer(r"(? bool: + raw = unicodedata.normalize("NFKC", str(text or "")) + expected = digits_only(phone)[-10:] + for match in _PHONE_RE.finditer(raw): + digits = digits_only(match.group(0)) + if digits[-10:] == expected and len(digits) in (10, 11) and _match_is_affirmative(raw, match): + return True + return False + + +_MERIDIEM = {"am": r"a\.?\s*m\b\.?", "pm": r"p\.?\s*m\b\.?"} + + +def _parse_clock(value: str) -> tuple[int, int, str]: + match = re.fullmatch(r"\s*(\d{1,2})(?::(\d{2}))?\s*([AaPp])\.?\s*[Mm]\.?\s*", str(value)) + if not match: + raise ValueError(f"unsupported clock time: {value!r}") + hour12 = int(match.group(1)) + minute = int(match.group(2) or 0) + meridiem = "am" if match.group(3).lower() == "a" else "pm" + if not 1 <= hour12 <= 12 or not 0 <= minute < 60: + raise ValueError(f"unsupported clock time: {value!r}") + return hour12, minute, meridiem + + +def _clock_pattern(value: str) -> str: + hour12, minute, meridiem = _parse_clock(value) + hour24 = hour12 % 12 + (12 if meridiem == "pm" else 0) + minutes = f":{minute:02d}" if minute else r"(?::00)?" + twelve_hour = rf"(? bool: + return _affirmative_search(_clock_pattern(value), normalize_text(text)) + + +def contains_hours_window(text: Any, opens: str, closes: str) -> bool: + """Both endpoints appear; ``8 am`` / ``8:00 AM`` / ``8:00 a.m.`` / ``08:00`` all count.""" + return contains_clock_time(text, opens) and contains_clock_time(text, closes) + + +_CLOCK_MASKS = ( + r"(? bool: + """``15 minutes`` / ``15 min`` / ``15-minute`` / ``wait time: 15``; clock times are masked first.""" + normalized = normalize_text(text) + for pattern in _CLOCK_MASKS: + normalized = re.sub(pattern, " ~ ", normalized) + value = int(minutes) + unit = rf"(? bool: + normalized = normalize_text(text) + value = int(stars) + patterns = [ + rf"(? bool: + """``November 2, 2022`` / ``Nov 2 2022`` / ``2 November 2022`` / ``2022-11-02`` / ``11/2/2022``.""" + normalized = normalize_text(text) + month = _MONTHS[value.month - 1] + month_re = rf"(?:{month}|{month[:3]}\.?)" + day_re = rf"(? bool: + """Number + core street tokens; suffix and directional synonyms tolerated (``Rd``/``Road``).""" + normalized = normalize_text(text) + tokens = normalize_text(street).replace(",", " ").split() + if not tokens: + return False + parts: list[str] = [] + for index, token in enumerate(tokens): + token = token.rstrip(".") + if token in _DIRECTIONALS: + alternatives = "|".join(re.escape(a) for a in _DIRECTIONALS[token]) + parts.append(rf"[\s,.]+(?:{alternatives})\b\.?") + elif token in _SUFFIXES: + alternatives = "|".join(re.escape(a) for a in _SUFFIXES[token]) + parts.append(rf"[\s,.]+(?:{alternatives})\b\.?") + elif re.fullmatch(r"[\d.]+", token): + separator = "" if index == 0 else r"[\s,.]+" + parts.append(rf"{separator}(? set[str]: + raw = unicodedata.normalize("NFKC", str(text or "")) + return {"WMD-" + match.group(1).upper() for match in _REFERENCE_RE.finditer(raw)} + + +def contains_reference(text: Any, reference: str) -> bool: + raw = unicodedata.normalize("NFKC", str(text or "")) + expected = str(reference or "").upper() + for match in _REFERENCE_RE.finditer(raw): + if "WMD-" + match.group(1).upper() == expected and _match_is_affirmative(raw, match): + return True + return False + + +def contains_url(text: Any, url: str) -> bool: + """Host + path compared after stripping the scheme, ``www.`` and a trailing slash.""" + expected = re.sub(r"^[a-z]+://", "", str(url or "").strip().casefold()) + expected = re.sub(r"^www\.", "", expected).rstrip("/") + if not expected: + return False + normalized = normalize_text(text) + pattern = r"(? bool: + marker = "PASS" if condition else "FAIL" + self.evidence.append(f"[{marker}] {name}: {evidence}") + if not condition: + self.passed = False + if not self.reason: + self.reason = name + return condition + + def emit(self) -> None: + result = { + "task_id": self.task_id, + "pass": self.passed, + "reason": self.reason or "all checks passed", + "evidence": self.evidence, + } + print(json.dumps(result, 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, + "infra_error": True, + "reason": reason, + "evidence": [f"[FAIL] {reason}: {detail}"], + }, + ensure_ascii=False, + indent=2, + ) + ) + raise SystemExit(1) + + +def _same_local_origin(url: str, start_url: str) -> bool: + try: + observed = urlparse(str(url or "")) + start = urlparse(str(start_url or "")) + return ( + observed.scheme == start.scheme == "http" + and observed.hostname is not None + and start.hostname is not None + and not observed.username + and not observed.password + and observed.port == start.port + and observed.hostname.casefold() == start.hostname.casefold() + and is_site_url(url) + ) + except ValueError: + return False + + +def _screenshots_decode(trajectory: dict[str, Any]) -> tuple[bool, str]: + root = Path(str(trajectory.get("_run_dir") or "")) + steps = trajectory.get("steps") + if not root.is_dir() or not isinstance(steps, list) or not steps: + return False, "run directory or steps are missing" + checked = 0 + for index, step in enumerate(steps): + if not isinstance(step, dict): + return False, f"step {index} is not an object" + for key in ("screenshot_before", "screenshot_after"): + name = step.get(key) + relative = Path(str(name or "")) + if not name or relative.is_absolute() or ".." in relative.parts: + return False, f"step {index} has unsafe {key}" + candidates = (root / "screenshots" / relative, root / relative) + path = next((item for item in candidates if item.is_file()), None) + if path is None: + return False, f"step {index} is missing {key}={name!r}" + try: + with Image.open(path) as image: + image.load() + if image.format != "PNG" or image.width < 1 or image.height < 1: + return False, f"step {index} {key} is not a nonempty PNG" + except Exception as exc: + return False, f"step {index} {key} cannot decode: {type(exc).__name__}" + checked += 1 + return True, f"decoded {checked} PNG screenshots" + + +def check_trajectory_identity(judge: Judge, trajectory: dict[str, Any], task_id: str) -> None: + judge.check( + "final_answer_nonempty", + bool(final_answer(trajectory)), + f"final_answer={final_answer(trajectory)!r}", + ) + judge.check( + "trajectory_task_matches", + trajectory_task_matches(trajectory, task_id), + f"expected_task_id={task_id!r}, observed_task_id={trajectory.get('task_id')!r}", + ) + steps = trajectory.get("steps") + judge.check( + "trajectory_completed", + trajectory.get("terminated") is True and trajectory.get("termination_reason") == "agent_done", + f"terminated={trajectory.get('terminated')!r}, reason={trajectory.get('termination_reason')!r}", + ) + judge.check("trajectory_has_steps", isinstance(steps, list) and bool(steps), f"steps={len(steps) if isinstance(steps, list) else 'invalid'}") + recorded = trajectory_urls(trajectory) + judge.check( + "all_urls_match_local_origin", + bool(recorded) and all(_same_local_origin(url, trajectory.get("start_url", "")) for url in recorded), + f"start_url={trajectory.get('start_url')!r}, recorded_urls={recorded!r}", + ) + screenshots_ok, screenshot_evidence = _screenshots_decode(trajectory) + judge.check("screenshots_decode", screenshots_ok, screenshot_evidence) + + +def check_signed_in_as(judge: Judge, trajectory: dict[str, Any], email: str) -> None: + judge.check("visited_login_page", navigated_to_path(trajectory, "/login"), "required_path=/login") + judge.check( + "entered_expected_account_email", + trajectory_last_email(trajectory) == normalize_text(email), + f"expected_email={email!r}, last_entered_email={trajectory_last_email(trajectory)!r}", + ) + + +def check_visited_path(judge: Judge, trajectory: dict[str, Any], name: str, path: str | re.Pattern[str]) -> bool: + described = f"/{path.pattern}/" if isinstance(path, re.Pattern) else path + return judge.check(name, navigated_to_path(trajectory, path), f"required_path={described}") + + +def check_visited_profile(judge: Judge, trajectory: dict[str, Any], slug: str) -> bool: + return judge.check( + f"visited_profile_{slug}", + profile_visited(trajectory, slug), + f"required_path=/doctor/{slug}-overview (tab aliases accepted)", + ) + + +def check_ended_on_profile(judge: Judge, trajectory: dict[str, Any], slug: str) -> bool: + return judge.check( + "ended_on_profile", + final_url_is_path(trajectory, profile_path_pattern(slug)), + f"required_final_path=/doctor/{slug}-overview, final_url={final_url(trajectory)!r}", + ) + + +def check_paths_in_order( + judge: Judge, + trajectory: dict[str, Any], + name: str, + requirements: Sequence[tuple[str | re.Pattern[str], dict[str, Any]]], +) -> bool: + urls = site_urls(trajectory) + cursor = 0 + described = [ + (f"/{path.pattern}/" if isinstance(path, re.Pattern) else path, _describe_params(params)) + for path, params in requirements + ] + for expected_path, params in requirements: + for index in range(cursor, len(urls)): + url = urls[index] + query = parse_qs(urlparse(url).query, keep_blank_values=True) + if _path_matches(url, expected_path) and all( + _param_matches(query, key, value) for key, value in params.items() + ): + cursor = index + 1 + break + else: + return judge.check(name, False, f"requirements={described!r}, observed={urls!r}") + return judge.check(name, True, f"requirements={described!r}") + + +def check_results_visited( + judge: Judge, trajectory: dict[str, Any], name: str, *alternatives: dict[str, Any], + path: str | re.Pattern[str] = "/results", +) -> bool: + """PASS when any of the ``alternatives`` param sets matches a visit of ``path``.""" + matched = any(results_visited(trajectory, path=path, **params) for params in alternatives) + described = " OR ".join(_describe_params(params) for params in alternatives) + shown_path = f"/{path.pattern}/" if isinstance(path, re.Pattern) else path + observed = [url for url in site_urls(trajectory) if _path_matches(url, path)] + return judge.check(name, matched, f"required={shown_path}?{described}; observed_urls={observed!r}") + + +# --------------------------------------------------------------------------- # +# SQLite state +# --------------------------------------------------------------------------- # +def db_query(db_path: str | os.PathLike[str], sql: str, params: Sequence[Any] = ()) -> list[sqlite3.Row]: + connection = sqlite3.connect(str(db_path)) + connection.row_factory = sqlite3.Row + try: + return connection.execute(sql, params).fetchall() + finally: + connection.close() + + +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"{SITE}_{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) + if result.returncode: + Path(destination).unlink(missing_ok=True) + detail = result.stderr.strip() or result.stdout.strip() + raise RuntimeError(f"could not copy {source}: {detail}") + atexit.register(Path(destination).unlink, missing_ok=True) + return destination + + +def resolve_db(explicit_path: str | None, container: str, kind: str) -> str | None: + if explicit_path: + path = Path(explicit_path) + return str(path) if path.is_file() else None + try: + return fetch_db(container, kind) + except (OSError, RuntimeError): + return None + + +EXPECTED_TABLES = { + "appointment_requests", "awards", "certifications", "cities", "city_zips", "conditions", + "doctor_conditions", "doctor_expertise", "doctor_insurances", "doctor_languages", + "doctor_perspectives", "doctor_procedures", "doctors", "education", "expertise_areas", + "hospitals", "insurance_plans", "insurers", "licenses", "locations", "practices", + "procedures", "reviews", "saved_providers", "seed_metadata", "specialties", + "user_reviews", "users", +} +IMMUTABLE_TABLES = tuple(sorted(EXPECTED_TABLES - set(READ_ONLY_TABLES))) +SCHEMA_HASH = "36413248f495b17db136370aa3316dcf1535c858c5dbda1ad349304c925b58a2" +SEED_VERSION = "webmd-doctor-v1" +EXPECTED_COUNTS = { + "specialties": 10, "cities": 8, "hospitals": 12, "practices": 30, "doctors": 226, + "locations": 348, "users": 4, "saved_providers": 4, "appointment_requests": 1, "user_reviews": 1, +} + + +def _schema_objects(db_path: str) -> list[tuple[Any, ...]]: + return [ + tuple(row) + for row in db_query( + db_path, + "SELECT type, name, tbl_name, sql FROM sqlite_schema " + "WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY type, name", + ) + ] + + +def _validate_snapshot_contract(initial_db: str, after_db: str) -> None: + table_sql = "SELECT name FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%'" + initial_tables = {row["name"] for row in db_query(initial_db, table_sql)} + after_tables = {row["name"] for row in db_query(after_db, table_sql)} + if initial_tables != EXPECTED_TABLES or after_tables != EXPECTED_TABLES: + raise ValueError(f"unexpected tables: initial={sorted(initial_tables)}, after={sorted(after_tables)}") + initial_schema = _schema_objects(initial_db) + if initial_schema != _schema_objects(after_db): + raise ValueError("initial and after database schemas differ") + schema_hash = hashlib.sha256(json.dumps(initial_schema, separators=(",", ":")).encode()).hexdigest() + if schema_hash != SCHEMA_HASH: + raise ValueError(f"unsupported WebMD Doctor schema hash: {schema_hash}") + marker = db_query(initial_db, "SELECT value FROM seed_metadata WHERE key='version'") + if len(marker) != 1 or marker[0]["value"] != SEED_VERSION: + raise ValueError("initial database seed version is missing or unsupported") + observed = {table: len(table_rows(initial_db, table)) for table in EXPECTED_COUNTS} + if observed != EXPECTED_COUNTS: + raise ValueError(f"initial database counts differ: expected={EXPECTED_COUNTS}, observed={observed}") + changed = [table for table in IMMUTABLE_TABLES if table_rows(initial_db, table) != table_rows(after_db, table)] + if changed: + raise ValueError(f"immutable catalog tables changed: {changed}") + + +def resolve_snapshots(args: VerifyArgs, task_id: str) -> tuple[str, str]: + """Return validated (initial_db, after_db) snapshots or fail closed.""" + initial_db = resolve_db(args.initial_db, args.container, "instance_seed") + after_db = resolve_db(args.after_db, args.container, "instance") + if not initial_db or not after_db: + fail_closed(task_id, "database_unavailable", "both initial and after webmd_doctor database snapshots are required") + try: + _validate_snapshot_contract(str(initial_db), str(after_db)) + from ground_truth import task_ground_truth + task_number = int(task_id.rsplit("--", 1)[1]) + task_ground_truth(str(initial_db), task_number) + except (ImportError, OSError, sqlite3.Error, ValueError) as exc: + fail_closed(task_id, "snapshot_contract_invalid", str(exc)) + return str(initial_db), str(after_db) + + +def table_rows(db_path: str, table: str) -> list[tuple[Any, ...]]: + if not re.fullmatch(r"[a-z_]+", table): + raise ValueError(f"unsupported table: {table}") + return [tuple(row) for row in db_query(db_path, f"SELECT * FROM {table} ORDER BY 1")] + + +def rows_where(db_path: str, table: str, **filters: Any) -> list[dict[str, Any]]: + if not re.fullmatch(r"[a-z_]+", table): + raise ValueError(f"unsupported table: {table}") + clauses, params = [], [] + for column, value in filters.items(): + if not re.fullmatch(r"[a-z_0-9]+", column): + raise ValueError(f"unsupported column: {column}") + clauses.append(f"{column} = ?") + params.append(value) + where = f" WHERE {' AND '.join(clauses)}" if clauses else "" + return [dict(row) for row in db_query(db_path, f"SELECT * FROM {table}{where} ORDER BY 1", params)] + + +def table_delta(initial_db: str, after_db: str, table: str) -> dict[str, list[Any]]: + before = {row[0]: row for row in table_rows(initial_db, table)} + after = {row[0]: row for row in table_rows(after_db, table)} + common = before.keys() & after.keys() + return { + "added": [after[key] for key in sorted(after.keys() - before.keys())], + "removed": [before[key] for key in sorted(before.keys() - after.keys())], + "changed": [(before[key], after[key]) for key in sorted(common) if before[key] != after[key]], + } + + +def new_table_rows(initial_db: str, after_db: str, table: str) -> list[dict[str, Any]]: + """Rows present in ``after`` whose primary key is absent from ``initial`` (as dicts).""" + initial_ids = {row[0] for row in table_rows(initial_db, table)} + return [row for row in rows_where(after_db, table) if list(row.values())[0] not in initial_ids] + + +def tables_unchanged(initial_db: str, after_db: str, tables: Iterable[str]) -> dict[str, bool]: + return {table: table_rows(initial_db, table) == table_rows(after_db, table) for table in tables} + + +def check_tables_unchanged(judge: Judge, initial_db: str, after_db: str, tables: Iterable[str], prefix: str = "") -> None: + """One ``_unchanged`` check per table.""" + for table, same in tables_unchanged(initial_db, after_db, tables).items(): + judge.check( + f"{prefix}{table}_unchanged", + same, + f"table={table}, initial_rows={len(table_rows(initial_db, table))}, " + f"after_rows={len(table_rows(after_db, table))}, identical={same}", + ) + + +def check_read_only(judge: Judge, initial_db: str, after_db: str) -> None: + """Read-only tasks: the four runtime tables must be row-identical.""" + check_tables_unchanged(judge, initial_db, after_db, READ_ONLY_TABLES, prefix="read_only_") + + +def check_exact_delta( + judge: Judge, initial_db: str, after_db: str, table: str, added: int = 0, removed: int = 0, changed: int = 0 +) -> dict[str, list[Any]]: + delta = table_delta(initial_db, after_db, table) + judge.check( + f"{table}_exact_delta", + len(delta["added"]) == added and len(delta["removed"]) == removed and len(delta["changed"]) == changed, + f"expected added={added} removed={removed} changed={changed}; delta={delta!r}", + ) + return delta + + +def user_id_for_email(db_path: str, email: str) -> int | None: + rows = db_query(db_path, "SELECT id FROM users WHERE lower(email) = lower(?) ORDER BY id LIMIT 1", (email,)) + return int(rows[0]["id"]) if rows else None + + +def user_emails(db_path: str) -> set[str]: + return {normalize_text(row["email"]) for row in db_query(db_path, "SELECT email FROM users") if row["email"]} + + +def new_user_rows(initial_db: str, after_db: str) -> list[dict[str, Any]]: + return new_table_rows(initial_db, after_db, "users") + + +def saved_doctor_ids(db_path: str, user_id: int | None) -> set[int]: + if user_id is None: + return set() + return {int(row["doctor_id"]) for row in db_query(db_path, "SELECT doctor_id FROM saved_providers WHERE user_id = ?", (user_id,))} + + +def saved_delta(initial_db: str, after_db: str, user_id: int | None) -> tuple[set[int], set[int]]: + before = saved_doctor_ids(initial_db, user_id) + after = saved_doctor_ids(after_db, user_id) + return after - before, before - after + + +def doctor_id_for_slug(db_path: str, slug: str) -> int | None: + rows = db_query(db_path, "SELECT id FROM doctors WHERE slug = ?", (slug,)) + return int(rows[0]["id"]) if rows else None + + +def review_text_matches(stored: Any, expected: str) -> bool: + """Whitespace/quote/case-insensitive equality; a trailing period may be dropped or added.""" + def canonical(value: Any) -> str: + return re.sub(r"[.!]+$", "", normalize_text(value)).strip() + + return canonical(stored) == canonical(expected) From 82af7ddd4cd90c5737142ed57636a4775ea1b049 Mon Sep 17 00:00:00 2001 From: evanz37 <89922710+evanz37@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:55:23 -0400 Subject: [PATCH 15/21] chore(webmd_doctor): backfill verifier_path + judge_rubric tasks.jsonl now carries the seven reviewer keys. The five contributor keys are byte-identical to the submitted rows; verifier_path points at sites/webmd_doctor/verify/verify_N.py; judge_rubric opens with the shared scoring preamble (step list authoritative, unverified is not contradicted, the verifier owns values and the database) followed by rule-only checkpoints. A dev-side validator asserts the key shape, ids 0-19, verifier files, byte identity of the contributor keys, and that no rubric contains a ground-truth token or a doctor, hospital, practice or city name absent from its own ques. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017yDUT66ukMXuASicT7yd6H --- sites/webmd_doctor/tasks.jsonl | 40 +++++++++++++++++----------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/sites/webmd_doctor/tasks.jsonl b/sites/webmd_doctor/tasks.jsonl index 40a60783..1a8b5883 100644 --- a/sites/webmd_doctor/tasks.jsonl +++ b/sites/webmd_doctor/tasks.jsonl @@ -1,20 +1,20 @@ -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--0", "ques": "Search for Dermatologists near Newark, DE 19711 and open the profile of Dr. Jonah Dimitriou. Report the medical school Dr. Dimitriou graduated from and the graduation year.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Dermatologist&sids=29244"} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--1", "ques": "Find Dr. Julian Zamora, a Cardiovascular Disease specialist whose primary office is in Wilmington, DE. Report the NPI number shown on the profile and the languages spoken.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Cardiologist&sids=29236"} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--2", "ques": "Search for Family Medicine doctors near Newark, DE 19711 and open Dr. Ruth Thackeray's profile. Report the phone number listed for the primary office and that office's Saturday hours.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Family+Medicine"} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--3", "ques": "Open the profile of Dr. Mateo Alvarado, a Neurologist in West Chester, PA. Besides the primary office, the Locations section lists one other office. Report that office's name and street address.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/neurology/pennsylvania/west-chester"} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--4", "ques": "Find Dr. Charles Villanueva, an Orthopedic Surgeon in Elkton, MD. From the Certifications, License, & Education section, report the board that certified them, the certification year, and the institution where they completed their residency.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/orthopedic-surgery/maryland/elkton"} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--5", "ques": "Search for Gastroenterologists near Newark, DE 19711 and open Dr. Caroline Danforth's profile. Among the five most-treated conditions shown, exactly one is marked \"More Than Most\". Which condition is it, and which condition is listed first under \"View Top 20\"?", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Gastroenterologist"} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--6", "ques": "Open the profile of Dr. Fatima Jensen, a Psychiatrist in Media, PA, and read all of their reviews. What is the date shown on the oldest review, and what star rating did that reviewer give?", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/psychiatry/pennsylvania/media"} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--7", "ques": "Find Dr. Lillian Acosta, an Obstetrics & Gynecology specialist in Salem, NJ. Which of the seven Patients' Perspective criteria received the most needs-improvement votes, and what average wait time is shown on the profile?", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/obstetrics-gynecology/new-jersey/salem"} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--8", "ques": "Log in as alice.j@test.com (password: TestPass123!) and open Saved Providers. Exactly one of your saved providers is a Dermatologist. Open that profile and report the institution where they completed their residency, then remove that provider from your saved list.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/"} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--9", "ques": "Search for Dermatologists near Newark, DE 19711 who are female, accept new patients and accept Blue Cross Blue Shield. Among the results, open the profile of the doctor with fewer than 5 years of experience and report their medical school and the year of their board certification.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Dermatologist&sids=29244"} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--10", "ques": "Find Psychiatrists near Newark, DE 19711 who accept Medicaid and have a rating of 4 stars or higher. Open the profile of the one who offers virtual visits and report the average wait time and the residency institution listed.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Psychiatrist"} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--11", "ques": "Set the distance to 10 miles from Newark, DE 19711, search for Family Medicine doctors and sort by Number of Ratings. Open the profile of the doctor with the second-highest number of ratings and report their NPI number and the institution where they completed their residency.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Family+Medicine"} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--12", "ques": "From the Find Providers by Specialty menu open Cardiovascular Disease, then Pennsylvania, then West Chester. Filter to doctors rated 4 stars or higher. Open the profile of the only male doctor in that list and report his fellowship institution and the year he completed it.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/cardiovascular-disease/pennsylvania/west-chester"} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--13", "ques": "Among Dermatologists in Wilmington, DE, Dr. Gregory Greenwood and Dr. Emerson Huang both accept Blue Cross Blue Shield. Which of the two graduated from medical school earlier? Report that doctor's name and graduation year.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/dermatology/delaware/wilmington"} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--14", "ques": "Open the hospital page for Christina Creek Medical Center (Find a Facility > Hospitals > Delaware). Two of its listed physicians are Psychiatrists; open both profiles. Which one was board certified more recently? Report that doctor's name and the certification year.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/hospitals/delaware"} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--15", "ques": "From the header, open Award Winning Hospitals, then view the WebMD Patient's Choice recipients. Find the recipient who practices Orthopedic Surgery in Media, PA, open their profile, then open the practice page linked from their primary office. Report the practice's website address and its Saturday hours.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/choice-awards/awardrecipients?award-class=patient"} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--16", "ques": "Log in with the demo account (email: bob.c@test.com, password: TestPass123!), search for Pediatricians near Newark, DE 19711, open the profile of Dr. Anita Castellano and save the provider. Then open Saved Providers and confirm Dr. Castellano appears there.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Pediatrician"} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--17", "ques": "Log in as carol.d@test.com (password: TestPass123!). Open the profile of Dr. Sarah Keller, a Cardiovascular Disease specialist in Newark, DE, and request an appointment as a New Patient at the Riverfront Heart & Vascular - Wellness Center office on Mon, Sep 14 at 10:30 AM. Report the confirmation reference shown after submitting.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Cardiologist&sids=29236"} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--18", "ques": "Log in as david.k@test.com (password: TestPass123!). Find Dr. Tariq Huang, a Dermatologist in Elkton, MD, and leave a 4-star review with the text \"Short wait and a clear explanation of my treatment options.\" Confirm the profile now shows your review as Pending review.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/reviews-guidelines"} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--19", "ques": "Register a new account with an email and password of your choice. Then search for Neurologists near Newark, DE 19711 who offer virtual visits, open the profile of Dr. Monica Carrington, and save the provider. Report the NPI number shown on Dr. Carrington's profile.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Neurologist"} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--0", "ques": "Search for Dermatologists near Newark, DE 19711 and open the profile of Dr. Jonah Dimitriou. Report the medical school Dr. Dimitriou graduated from and the graduation year.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Dermatologist&sids=29244", "verifier_path": "sites/webmd_doctor/verify/verify_0.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: A results page for Dermatologists near Newark, DE 19711 must have been visited and the named doctor's own profile opened (a same-surname doctor is not the named doctor). The answer must include the medical school name and the graduation year as shown in the profile's Education section; a year taken from a residency or fellowship row is wrong. An empty answer, a missing profile visit, or a fact not associated with this doctor = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--1", "ques": "Find Dr. Julian Zamora, a Cardiovascular Disease specialist whose primary office is in Wilmington, DE. Report the NPI number shown on the profile and the languages spoken.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Cardiologist&sids=29236", "verifier_path": "sites/webmd_doctor/verify/verify_1.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The profile of the Wilmington, DE cardiologist with exactly this name must be opened (a same-surname doctor in another city does not count). The answer must include the 10-digit NPI exactly as printed on the profile and every language listed under Languages. A partial language list, an NPI not visible on the opened profile, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--2", "ques": "Search for Family Medicine doctors near Newark, DE 19711 and open Dr. Ruth Thackeray's profile. Report the phone number listed for the primary office and that office's Saturday hours.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Family+Medicine", "verifier_path": "sites/webmd_doctor/verify/verify_2.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: A Family Medicine results page near Newark, DE 19711 and the named doctor's profile must both be visited. The answer must include the primary office phone number (digits must match what the profile shows) and both endpoints of that office's Saturday opening window from the hours table. Weekday hours, 'Closed' when the office is open, a phone from another office, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--3", "ques": "Open the profile of Dr. Mateo Alvarado, a Neurologist in West Chester, PA. Besides the primary office, the Locations section lists one other office. Report that office's name and street address.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/neurology/pennsylvania/west-chester", "verifier_path": "sites/webmd_doctor/verify/verify_3.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The named neurologist's profile must be opened. The answer must name the office in the Locations section that is NOT the primary office and give its street address (number and street). Reporting the primary office, an address from a practice page, only a city, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--4", "ques": "Find Dr. Charles Villanueva, an Orthopedic Surgeon in Elkton, MD. From the Certifications, License, & Education section, report the board that certified them, the certification year, and the institution where they completed their residency.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/orthopedic-surgery/maryland/elkton", "verifier_path": "sites/webmd_doctor/verify/verify_4.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The named orthopedic surgeon's profile must be opened. The answer must include the certifying board's name, the certification year and the residency institution, all read from the Certifications, License, & Education section. A fellowship or medical-school row substituted for the residency, a year taken from another row, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--5", "ques": "Search for Gastroenterologists near Newark, DE 19711 and open Dr. Caroline Danforth's profile. Among the five most-treated conditions shown, exactly one is marked \"More Than Most\". Which condition is it, and which condition is listed first under \"View Top 20\"?", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Gastroenterologist", "verifier_path": "sites/webmd_doctor/verify/verify_5.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: A Gastroenterologist results page near Newark, DE 19711 and the named doctor's profile must both be visited. The answer must name the single condition marked 'More Than Most' among the five conditions shown, and the condition listed first after expanding 'View Top 20'. The 'More Often' tier is not 'More Than Most'; giving only one of the two facts, or an empty answer, = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--6", "ques": "Open the profile of Dr. Fatima Jensen, a Psychiatrist in Media, PA, and read all of their reviews. What is the date shown on the oldest review, and what star rating did that reviewer give?", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/psychiatry/pennsylvania/media", "verifier_path": "sites/webmd_doctor/verify/verify_6.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The named psychiatrist's profile must be opened and every review page read (the review list is paginated; the last page must be visited). The answer must give the date printed on the oldest review and that review's star rating. The date of the newest or featured review, a rating without a date or a date without a rating, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--7", "ques": "Find Dr. Lillian Acosta, an Obstetrics & Gynecology specialist in Salem, NJ. Which of the seven Patients' Perspective criteria received the most needs-improvement votes, and what average wait time is shown on the profile?", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/obstetrics-gynecology/new-jersey/salem", "verifier_path": "sites/webmd_doctor/verify/verify_7.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The named OBGYN's profile must be opened. The answer must name the Patients' Perspective criterion with the largest Needs Improvement count (the number in parentheses, not the percentage bar) and the Average Wait Time value in minutes shown on the profile. A criterion chosen by its Did Well count, a wait time from another profile, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--8", "ques": "Log in as alice.j@test.com (password: TestPass123!) and open Saved Providers. Exactly one of your saved providers is a Dermatologist. Open that profile and report the institution where they completed their residency, then remove that provider from your saved list.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/", "verifier_path": "sites/webmd_doctor/verify/verify_8.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The login step with the stated e-mail, the Saved Providers page, the opened profile of the one saved Dermatologist, and evidence of the removal (the list without that provider, or the provider's un-saved state) must all be visible, in that order. The answer must include the residency institution from that profile. Removing a different provider, removing nothing, reporting a fellowship or medical school instead of the residency, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--9", "ques": "Search for Dermatologists near Newark, DE 19711 who are female, accept new patients and accept Blue Cross Blue Shield. Among the results, open the profile of the doctor with fewer than 5 years of experience and report their medical school and the year of their board certification.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Dermatologist&sids=29244", "verifier_path": "sites/webmd_doctor/verify/verify_9.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: A Dermatologist results page near Newark, DE 19711 with the Female, Accepts New Patients and named-insurer filters all applied together must be visible. The profile opened must be the one whose card shows fewer than 5 years of experience. The answer must include the medical school and the board-certification year from the Certifications section (not the graduation year). Filters applied one at a time but never combined, a profile with 5 or more years of experience, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--10", "ques": "Find Psychiatrists near Newark, DE 19711 who accept Medicaid and have a rating of 4 stars or higher. Open the profile of the one who offers virtual visits and report the average wait time and the residency institution listed.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Psychiatrist", "verifier_path": "sites/webmd_doctor/verify/verify_10.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: A Psychiatrist results page near Newark, DE 19711 with Accepts Medicaid and the 4-stars-and-up rating filter applied together must be visible. The profile opened must be the one card in that list that shows telehealth / virtual visits. The answer must include the Average Wait Time in minutes and the residency institution. Reporting the medical school instead of the residency, a filter never applied, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--11", "ques": "Set the distance to 10 miles from Newark, DE 19711, search for Family Medicine doctors and sort by Number of Ratings. Open the profile of the doctor with the second-highest number of ratings and report their NPI number and the institution where they completed their residency.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Family+Medicine", "verifier_path": "sites/webmd_doctor/verify/verify_11.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: A Family Medicine results page near Newark, DE 19711 with the distance set to 10 miles and the sort set to Number of Ratings, both applied, must be visible. The profile opened must be the second card of that sorted list. The answer must include the 10-digit NPI and the residency institution from that profile. The top card, an unsorted or default-distance list, a fellowship reported as the residency, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--12", "ques": "From the Find Providers by Specialty menu open Cardiovascular Disease, then Pennsylvania, then West Chester. Filter to doctors rated 4 stars or higher. Open the profile of the only male doctor in that list and report his fellowship institution and the year he completed it.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/cardiovascular-disease/pennsylvania/west-chester", "verifier_path": "sites/webmd_doctor/verify/verify_12.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The Cardiovascular Disease specialty page, its Pennsylvania page and the West Chester page must be visited in that order via the Find Providers by Specialty menu path, and the West Chester list must then be filtered to 4 stars & up. The profile opened must be the only male doctor remaining in that filtered list. The answer must include the fellowship institution and the fellowship completion year (not residency). A profile reached from a search results page instead of this path, an unfiltered list, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--13", "ques": "Among Dermatologists in Wilmington, DE, Dr. Gregory Greenwood and Dr. Emerson Huang both accept Blue Cross Blue Shield. Which of the two graduated from medical school earlier? Report that doctor's name and graduation year.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/dermatology/delaware/wilmington", "verifier_path": "sites/webmd_doctor/verify/verify_13.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: Both named profiles must be opened (the graduation year is only shown on a profile). The answer must name the doctor who graduated from medical school earlier, with first and last name, and give that doctor's graduation year. Naming the other doctor, giving the later year, a surname alone, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--14", "ques": "Open the hospital page for Christina Creek Medical Center (Find a Facility > Hospitals > Delaware). Two of its listed physicians are Psychiatrists; open both profiles. Which one was board certified more recently? Report that doctor's name and the certification year.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/hospitals/delaware", "verifier_path": "sites/webmd_doctor/verify/verify_14.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The Delaware hospitals list and the named hospital's page must be visited, then both Psychiatrist profiles listed on that hospital page opened. The answer must name the doctor whose board-certification year is more recent, with first and last name, and give that certification year. Naming the other doctor, a graduation year in place of the certification year, profiles of a different specialty, profiles reached without the hospital page, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--15", "ques": "From the header, open Award Winning Hospitals, then view the WebMD Patient's Choice recipients. Find the recipient who practices Orthopedic Surgery in Media, PA, open their profile, then open the practice page linked from their primary office. Report the practice's website address and its Saturday hours.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/choice-awards/awardrecipients?award-class=patient", "verifier_path": "sites/webmd_doctor/verify/verify_15.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The Award Winning Hospitals page, the Patient's Choice recipients list, the matching recipient's profile and the practice page linked from that profile's primary office must all be visited, in that order. The answer must include the practice's website address exactly as printed (including its domain ending) and both endpoints of the practice's Saturday hours. Hours copied from the doctor profile without opening the practice page, another award class's list, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--16", "ques": "Log in with the demo account (email: bob.c@test.com, password: TestPass123!), search for Pediatricians near Newark, DE 19711, open the profile of Dr. Anita Castellano and save the provider. Then open Saved Providers and confirm Dr. Castellano appears there.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Pediatrician", "verifier_path": "sites/webmd_doctor/verify/verify_16.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: Login with the stated e-mail, a Pediatrician results page near Newark, DE 19711, the named doctor's profile with the save action (the button changes to Saved Provider or a saved confirmation appears), and then the Saved Providers page listing that doctor must all be visible, in this order. A Saved Providers page opened before the save does not count as confirmation. An answer claiming success without the list showing the doctor, or an empty answer, = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--17", "ques": "Log in as carol.d@test.com (password: TestPass123!). Open the profile of Dr. Sarah Keller, a Cardiovascular Disease specialist in Newark, DE, and request an appointment as a New Patient at the Riverfront Heart & Vascular - Wellness Center office on Mon, Sep 14 at 10:30 AM. Report the confirmation reference shown after submitting.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Cardiologist&sids=29236", "verifier_path": "sites/webmd_doctor/verify/verify_17.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: Login with the stated e-mail, the named cardiologist's profile, the appointment request page and a confirmation page showing a confirmation reference must be visible, in that order. The request shown on the confirmation page must be at the office named in the task, as a New Patient, on the stated date and time. The answer must include the reference exactly as displayed on the confirmation page; a reference that is not visible there, a request at a different office, patient type or time, or an empty answer = FAIL. The deterministic verifier owns the database match."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--18", "ques": "Log in as david.k@test.com (password: TestPass123!). Find Dr. Tariq Huang, a Dermatologist in Elkton, MD, and leave a 4-star review with the text \"Short wait and a clear explanation of my treatment options.\" Confirm the profile now shows your review as Pending review.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/reviews-guidelines", "verifier_path": "sites/webmd_doctor/verify/verify_18.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: Login with the stated e-mail and the named Elkton dermatologist's profile must be visible. The review form must be submitted with exactly 4 stars and the quoted text verbatim, and after submitting the profile must show the new review marked Pending review (the run must end on that profile). A different star count, altered text, a review left on a same-surname doctor, no visible pending review, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--19", "ques": "Register a new account with an email and password of your choice. Then search for Neurologists near Newark, DE 19711 who offer virtual visits, open the profile of Dr. Monica Carrington, and save the provider. Report the NPI number shown on Dr. Carrington's profile.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Neurologist", "verifier_path": "sites/webmd_doctor/verify/verify_19.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: A completed registration (the Sign Up page followed by a signed-in header), a Neurologist results page near Newark, DE 19711 with the Virtual Visit filter applied, the named doctor's profile and its save action must all be visible. The answer must include the 10-digit NPI shown on that profile. Logging in with a pre-existing demo account instead of registering, a results page without the virtual-visit filter, or an empty answer = FAIL."} From df19ae7eb17fbc08c86483ca80d8f80e93162c58 Mon Sep 17 00:00:00 2001 From: evanz37 <89922710+evanz37@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:33:13 -0400 Subject: [PATCH 16/21] test(webmd_doctor): harden verifiers from the validation matrix LLM-free matrix (116 scripted Playwright runs graded through eval_judge.py --verifier True) showed the five stateful verifiers ran row-identity / exactly_one_* checks before
    _exact_delta, so a run that wrote nothing failed under the wrong name. Exact delta now comes first; the redundant exactly_one_* count checks are gone; unit tests updated (397 green). README gains the validation summary (counts only). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WM3oHCY7Vi612v5c6JSfay --- sites/webmd_doctor/verify/README.md | 6 +++++- sites/webmd_doctor/verify/tests/test_verify_16.py | 4 ++-- sites/webmd_doctor/verify/tests/test_verify_17.py | 4 ++-- sites/webmd_doctor/verify/tests/test_verify_18.py | 4 ++-- sites/webmd_doctor/verify/tests/test_verify_19.py | 6 +++--- sites/webmd_doctor/verify/tests/test_verify_8.py | 4 ++-- sites/webmd_doctor/verify/verify_16.py | 2 +- sites/webmd_doctor/verify/verify_17.py | 3 +-- sites/webmd_doctor/verify/verify_18.py | 3 +-- sites/webmd_doctor/verify/verify_19.py | 6 ++---- sites/webmd_doctor/verify/verify_8.py | 2 +- 11 files changed, 22 insertions(+), 22 deletions(-) diff --git a/sites/webmd_doctor/verify/README.md b/sites/webmd_doctor/verify/README.md index a909f30f..7c0b2baf 100644 --- a/sites/webmd_doctor/verify/README.md +++ b/sites/webmd_doctor/verify/README.md @@ -21,7 +21,7 @@ Every run must provide the exact task ID, a nonempty final answer, `terminated: Both snapshots must have the exact 28-table WebMD Doctor schema (hash pinned), `seed_metadata.version = webmd-doctor-v1`, and the frozen seed counts (226 doctors, 348 locations, 12 hospitals, 30 practices, 10 specialties, 4 users, 4 saved providers, 1 appointment request, 1 pending user review). The 24 catalog tables must be row-identical before and after. `ground_truth.py` then re-derives the task's target from the initial snapshot the way the task text selects it (specialty + city, combined filters within the search radius, the saved list, a hospital roster, an award class) and fails closed if it disagrees with the constants hardcoded in the verifier. -The four runtime tables are `users`, `saved_providers`, `appointment_requests` and `user_reviews`. Read-only tasks (0-7, 9-15) require all four to be row-identical, so an incidental save or review fails. Stateful verifiers (8, 16, 17, 18, 19) enforce the exact row delta on the touched table and identity on the others: removing the wrong saved provider, a second booking, a review on a same-surname doctor, a registration with a different e-mail than the one typed on the Sign Up page, or a save under a demo account all fail on a named check. +The four runtime tables are `users`, `saved_providers`, `appointment_requests` and `user_reviews`. Read-only tasks (0-7, 9-15) require all four to be row-identical, so an incidental save or review fails. Stateful verifiers (8, 16, 17, 18, 19) check the exact row delta on the touched table first (`
    _exact_delta`: nothing written and a duplicate write both stop there, with the delta in the evidence), then the identity of the new or removed row (owner, doctor, office, slot, stars, text, e-mail typed on the Sign Up page), then identity on the other runtime tables (`
    _unchanged`). Removing the wrong saved provider, a review on a same-surname doctor, a booking at the primary office or a save under a demo account each fail on a named row check; a collateral write in another table fails on that table's `_unchanged` check. ## Gates and answer matchers @@ -29,6 +29,10 @@ Profile visits match `/doctor/-overview` and its three tab aliases; bookin Answer matchers are negation-aware whole-token matches: institution and office names (punctuation, dash style and `&`/`and` ignored), years as standalone four-digit tokens, NPIs as one 10-digit token, phones by digit comparison, opening windows by both clock endpoints (`8 am`, `8:00 AM`, `08:00`), wait times in minutes with clock times masked, star ratings tied to a star/rating token, review dates in six formats, first + last name for comparison winners, the booking reference matched against the new database row (never a literal, and no other reference may appear), and the practice website with its domain ending. +## Validation (LLM-free matrix) + +Every verifier was exercised end to end through `agent_demo/eval_judge.py --run_dir --verifier True` on scripted Playwright runs that reproduce the `agent_demo/agent.py` run signature (trajectory with url-before-action steps, typed text in `input` params, PNG screenshots, `initial.db` copied after `POST /reset/webmd_doctor`, `after.db` copied when the run ends). Rows: no-op (20 tasks), genuine click-walk (20), knowledge shortcut with the correct answer but no profile or route visit (20), genuine path with one decoy fact (18), stateful path with the write skipped (5), genuine path plus a collateral write in another runtime table (5), genuine path plus a duplicate write in the same table (5), read-only task with one incidental save while logged in (15), and the mandated route bypassed (8). 116 cells; every genuine run passes and every other cell fails on the intended check (first failing check recorded per cell). The matrix exposed one verifier defect, fixed before this commit: the stateful verifiers ran the row-identity or `exactly_one_*` count check before `
    _exact_delta`, so "nothing written" surfaced under the wrong name; the delta check now comes first and the redundant count checks were removed. A run with no snapshots and an unreachable container exits 1 with `infra_error: true`; `POST /reset/webmd_doctor` after the whole matrix restores the byte-identical seed. No LLM is involved anywhere in the matrix. + ## Tests ```bash diff --git a/sites/webmd_doctor/verify/tests/test_verify_16.py b/sites/webmd_doctor/verify/tests/test_verify_16.py index ff794ec8..4f12c295 100644 --- a/sites/webmd_doctor/verify/tests/test_verify_16.py +++ b/sites/webmd_doctor/verify/tests/test_verify_16.py @@ -45,7 +45,7 @@ def test_saved_page_before_profile_fails_order(self) -> None: self.assertFailsOn(self.verdict(steps, ANSWER, after=genuine_after()), "workflow_in_order") def test_state_unchanged_fails(self) -> None: - self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=State()), "new_saved_row_belongs_to_bob") + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=State()), "saved_providers_exact_delta") def test_saved_under_other_account_fails(self) -> None: after = State() @@ -60,7 +60,7 @@ def test_saved_wrong_doctor_fails(self) -> None: def test_extra_save_fails(self) -> None: after = genuine_after() after.add_saved(2, 5) - self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "new_saved_row_belongs_to_bob") + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "saved_providers_exact_delta") def test_wrong_account_fails(self) -> None: steps = [step("/"), *login_steps("alice.j@test.com"), *GENUINE_STEPS[4:]] diff --git a/sites/webmd_doctor/verify/tests/test_verify_17.py b/sites/webmd_doctor/verify/tests/test_verify_17.py index f1cdc870..2def1d3d 100644 --- a/sites/webmd_doctor/verify/tests/test_verify_17.py +++ b/sites/webmd_doctor/verify/tests/test_verify_17.py @@ -44,12 +44,12 @@ def test_shortcut_fails_on_gate(self) -> None: self.assertFailsOn(self.verdict(steps, ANSWER, after=genuine_after()), "visited_booking_page") def test_state_unchanged_fails(self) -> None: - self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=State()), "exactly_one_new_request") + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=State()), "appointment_requests_exact_delta") def test_two_requests_fail(self) -> None: after = genuine_after() after.add_appointment(3, 22, 32, reference="WMD-ZZ2ZZ3ZZ") - self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "exactly_one_new_request") + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "appointment_requests_exact_delta") def test_primary_office_fails(self) -> None: after = State() diff --git a/sites/webmd_doctor/verify/tests/test_verify_18.py b/sites/webmd_doctor/verify/tests/test_verify_18.py index 1e08f2ae..953f9790 100644 --- a/sites/webmd_doctor/verify/tests/test_verify_18.py +++ b/sites/webmd_doctor/verify/tests/test_verify_18.py @@ -49,7 +49,7 @@ def test_ended_elsewhere_fails(self) -> None: self.assertFailsOn(self.verdict(steps, ANSWER, after=genuine_after()), "ended_on_profile") def test_state_unchanged_fails(self) -> None: - self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=State()), "exactly_one_new_review") + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=State()), "user_reviews_exact_delta") def test_wrong_rating_fails(self) -> None: after = State() @@ -79,7 +79,7 @@ def test_other_account_fails(self) -> None: def test_two_reviews_fail(self) -> None: after = genuine_after() after.add_review(4, 18, 4, TEXT) - self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "exactly_one_new_review") + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "user_reviews_exact_delta") def test_wrong_answer_0_fails(self) -> None: verdict = self.verdict(GENUINE_STEPS, 'Review submitted successfully.', after=genuine_after()) diff --git a/sites/webmd_doctor/verify/tests/test_verify_19.py b/sites/webmd_doctor/verify/tests/test_verify_19.py index 7fc7e3cb..2e40faee 100644 --- a/sites/webmd_doctor/verify/tests/test_verify_19.py +++ b/sites/webmd_doctor/verify/tests/test_verify_19.py @@ -53,7 +53,7 @@ def test_virtual_typed_in_search_passes(self) -> None: def test_no_registration_fails(self) -> None: after = State() after.add_saved(1, 68) - self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "exactly_one_new_user") + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "users_exact_delta") def test_registered_with_other_email_fails(self) -> None: after = State() @@ -64,7 +64,7 @@ def test_registered_with_other_email_fails(self) -> None: def test_registered_but_not_saved_fails(self) -> None: after = State() after.add_user(EMAIL) - self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "exactly_one_new_saved_row") + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "saved_providers_exact_delta") def test_saved_under_seed_account_fails(self) -> None: after = State() @@ -81,7 +81,7 @@ def test_saved_wrong_doctor_fails(self) -> None: def test_two_accounts_fail(self) -> None: after = genuine_after() after.add_user("second@example.com") - self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "exactly_one_new_user") + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "users_exact_delta") def test_wrong_answer_0_fails(self) -> None: verdict = self.verdict(GENUINE_STEPS, 'Registered and saved the provider. NPI: 1279956603', after=genuine_after()) diff --git a/sites/webmd_doctor/verify/tests/test_verify_8.py b/sites/webmd_doctor/verify/tests/test_verify_8.py index e77de4c6..ad535e2c 100644 --- a/sites/webmd_doctor/verify/tests/test_verify_8.py +++ b/sites/webmd_doctor/verify/tests/test_verify_8.py @@ -49,7 +49,7 @@ def test_wrong_account_fails(self) -> None: self.assertFailsOn(self.verdict(steps, ANSWER, after=genuine_after()), "entered_expected_account_email") def test_state_unchanged_fails(self) -> None: - self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=State()), "removed_target_only") + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=State()), "saved_providers_exact_delta") def test_wrong_row_removed_fails(self) -> None: after = State() @@ -59,7 +59,7 @@ def test_wrong_row_removed_fails(self) -> None: def test_two_rows_removed_fails(self) -> None: after = genuine_after() after.remove_saved(1, 47) - self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "removed_target_only") + self.assertFailsOn(self.verdict(GENUINE_STEPS, ANSWER, after=after), "saved_providers_exact_delta") def test_collateral_write_fails(self) -> None: after = genuine_after() diff --git a/sites/webmd_doctor/verify/verify_16.py b/sites/webmd_doctor/verify/verify_16.py index 8ad5b01d..65e5ffc2 100644 --- a/sites/webmd_doctor/verify/verify_16.py +++ b/sites/webmd_doctor/verify/verify_16.py @@ -50,9 +50,9 @@ def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) - check_visited_path(judge, trajectory, "visited_saved_providers_page", SAVED_PATH) check_paths_in_order(judge, trajectory, "workflow_in_order", [("/login", {}), ("/results", {}), (profile_path_pattern(SLUG), {}), (SAVED_PATH, {})]) judge.check("answer_confirms_saved", contains_any(answer, ("Castellano", "saved", "appears", "listed")), f"answer={answer!r}") + check_exact_delta(judge, initial_db, after_db, "saved_providers", added=1) added, removed = saved_delta(initial_db, after_db, USER_ID) judge.check("new_saved_row_belongs_to_bob", added == {DOCTOR_ID} and not removed, f"expected added=[{DOCTOR_ID}] removed=[]; observed added={sorted(added)!r} removed={sorted(removed)!r}") - check_exact_delta(judge, initial_db, after_db, "saved_providers", added=1) check_tables_unchanged(judge, initial_db, after_db, ("users", "appointment_requests", "user_reviews")) diff --git a/sites/webmd_doctor/verify/verify_17.py b/sites/webmd_doctor/verify/verify_17.py index 3dae8e9c..426276e5 100644 --- a/sites/webmd_doctor/verify/verify_17.py +++ b/sites/webmd_doctor/verify/verify_17.py @@ -53,8 +53,8 @@ def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) - check_visited_path(judge, trajectory, "visited_booking_page", BOOKING_PATH) check_paths_in_order(judge, trajectory, "workflow_in_order", [("/login", {}), (profile_path_pattern(SLUG), {}), (BOOKING_PATH, {})]) judge.check("initial_has_no_carol_request_for_target", not rows_where(initial_db, "appointment_requests", user_id=USER_ID, doctor_id=DOCTOR_ID), f"user_id={USER_ID}, doctor_id={DOCTOR_ID}") + check_exact_delta(judge, initial_db, after_db, "appointment_requests", added=1) new_rows = new_table_rows(initial_db, after_db, "appointment_requests") - judge.check("exactly_one_new_request", len(new_rows) == 1, f"new_rows={new_rows!r}") row = new_rows[0] if len(new_rows) == 1 else {} judge.check("new_request_belongs_to_carol", row.get("user_id") == USER_ID, f"expected_user_id={USER_ID}, row={row!r}") judge.check("new_request_is_for_target", row.get("doctor_id") == DOCTOR_ID, f"expected_doctor_id={DOCTOR_ID}, row={row!r}") @@ -68,7 +68,6 @@ def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) - reference = str(row.get("reference") or "") judge.check("answer_has_matching_reference", bool(reference) and contains_reference(answer, reference), f"row_reference={reference!r}, answer={answer!r}") judge.check("answer_has_no_other_reference", extract_references(answer) <= ({reference} if reference else set()), f"answer_references={sorted(extract_references(answer))!r}") - check_exact_delta(judge, initial_db, after_db, "appointment_requests", added=1) check_tables_unchanged(judge, initial_db, after_db, ("users", "saved_providers", "user_reviews")) diff --git a/sites/webmd_doctor/verify/verify_18.py b/sites/webmd_doctor/verify/verify_18.py index f449a272..f97c4508 100644 --- a/sites/webmd_doctor/verify/verify_18.py +++ b/sites/webmd_doctor/verify/verify_18.py @@ -51,8 +51,8 @@ def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) - check_paths_in_order(judge, trajectory, "workflow_in_order", [("/login", {}), (profile_path_pattern(SLUG), {})]) check_ended_on_profile(judge, trajectory, SLUG) judge.check("initial_has_no_david_review_for_target", not rows_where(initial_db, "user_reviews", user_id=USER_ID, doctor_id=DOCTOR_ID), f"user_id={USER_ID}, doctor_id={DOCTOR_ID}") + check_exact_delta(judge, initial_db, after_db, "user_reviews", added=1) new_rows = new_table_rows(initial_db, after_db, "user_reviews") - judge.check("exactly_one_new_review", len(new_rows) == 1, f"new_rows={new_rows!r}") row = new_rows[0] if len(new_rows) == 1 else {} judge.check("new_review_belongs_to_david", row.get("user_id") == USER_ID, f"expected_user_id={USER_ID}, row={row!r}") judge.check("new_review_is_for_target", row.get("doctor_id") == DOCTOR_ID, f"expected_doctor_id={DOCTOR_ID}, row={row!r}") @@ -60,7 +60,6 @@ def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) - judge.check("new_review_text_matches", review_text_matches(row.get("text"), TEXT), f"expected={TEXT!r}, row_text={row.get('text')!r}") judge.check("new_review_status_pending", str(row.get("status") or "").casefold() == STATUS.casefold(), f"expected={STATUS!r}, row={row!r}") judge.check("answer_confirms_pending", contains_any(answer, ("pending",)), f"answer={answer!r}") - check_exact_delta(judge, initial_db, after_db, "user_reviews", added=1) check_tables_unchanged(judge, initial_db, after_db, ("users", "saved_providers", "appointment_requests")) diff --git a/sites/webmd_doctor/verify/verify_19.py b/sites/webmd_doctor/verify/verify_19.py index 1efee7b9..9fd47a99 100644 --- a/sites/webmd_doctor/verify/verify_19.py +++ b/sites/webmd_doctor/verify/verify_19.py @@ -55,18 +55,16 @@ def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) - check_visited_profile(judge, trajectory, SLUG) check_paths_in_order(judge, trajectory, "workflow_in_order", [("/signup", {}), ("/results", {}), (profile_path_pattern(SLUG), {})]) typed_email = signup_email(trajectory) + check_exact_delta(judge, initial_db, after_db, "users", added=1) new_users = new_user_rows(initial_db, after_db) - judge.check("exactly_one_new_user", len(new_users) == 1, f"new_users={[row.get('email') for row in new_users]!r}") user = new_users[0] if len(new_users) == 1 else {} judge.check("new_user_email_matches_signup_input", bool(typed_email) and normalize_text(user.get("email")) == typed_email, f"typed_on_signup={typed_email!r}, new_user_email={user.get('email')!r}") judge.check("new_user_is_not_a_seed_account", bool(user) and normalize_text(user.get("email")) not in user_emails(initial_db), f"new_user_email={user.get('email')!r}") - check_exact_delta(judge, initial_db, after_db, "users", added=1) + check_exact_delta(judge, initial_db, after_db, "saved_providers", added=1) new_saved = new_table_rows(initial_db, after_db, "saved_providers") - judge.check("exactly_one_new_saved_row", len(new_saved) == 1, f"new_saved={new_saved!r}") saved = new_saved[0] if len(new_saved) == 1 else {} judge.check("new_saved_row_belongs_to_new_user", bool(user) and saved.get("user_id") == user.get("id"), f"new_user_id={user.get('id')!r}, row={saved!r}") judge.check("new_saved_row_is_for_target", saved.get("doctor_id") == DOCTOR_ID, f"expected_doctor_id={DOCTOR_ID}, row={saved!r}") - check_exact_delta(judge, initial_db, after_db, "saved_providers", added=1) judge.check("answer_has_npi", contains_npi(answer, NPI), f"expected={NPI!r}, answer={answer!r}") check_tables_unchanged(judge, initial_db, after_db, ("appointment_requests", "user_reviews")) diff --git a/sites/webmd_doctor/verify/verify_8.py b/sites/webmd_doctor/verify/verify_8.py index 2546d5aa..88c1f709 100644 --- a/sites/webmd_doctor/verify/verify_8.py +++ b/sites/webmd_doctor/verify/verify_8.py @@ -50,9 +50,9 @@ def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) - check_paths_in_order(judge, trajectory, "workflow_in_order", [("/login", {}), (SAVED_PATH, {}), (profile_path_pattern(SLUG), {})]) judge.check("initial_alice_has_target_saved", DOCTOR_ID in saved_doctor_ids(initial_db, USER_ID), f"initial_saved={sorted(saved_doctor_ids(initial_db, USER_ID))!r}") judge.check("answer_has_residency", contains_institution(answer, RESIDENCY), f"expected={RESIDENCY!r}, answer={answer!r}") + check_exact_delta(judge, initial_db, after_db, "saved_providers", removed=1) added, removed = saved_delta(initial_db, after_db, USER_ID) judge.check("removed_target_only", removed == {DOCTOR_ID} and not added, f"expected removed={{{DOCTOR_ID}}} added=set(); observed removed={sorted(removed)!r} added={sorted(added)!r}") - check_exact_delta(judge, initial_db, after_db, "saved_providers", removed=1) check_tables_unchanged(judge, initial_db, after_db, ("users", "appointment_requests", "user_reviews")) From e7862ed19f00d3736429bede888bd21778121e84 Mon Sep 17 00:00:00 2001 From: evanz37 <89922710+evanz37@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:23:15 -0400 Subject: [PATCH 17/21] chore(webmd_doctor): polish judge rubrics from real agent runs Rules-only rewrite from 40 dual-graded agent runs (gpt-5.4-nano and gpt-5.4-mini, judge on nano): the shared preamble now states the screenshot window, that the final answer text is the evidence, and that an uncontradicted checkpoint set means success; "must be visible" checkpoints (8-11, 16-19) and "as shown in the section" facts (0, 4, 5, 9) are phrased against the step list. validate_tasks: contributor keys byte-identical, no ground-truth token. README records the real-run counts (verifier 1/20 nano, 7/20 mini; 0 verifier defects; 0 judge false PASSes; agreement 19/20 and 15/20). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UB1bWfYQV98foy5iCKxP1H --- sites/webmd_doctor/tasks.jsonl | 40 ++++++++++++++--------------- sites/webmd_doctor/verify/README.md | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/sites/webmd_doctor/tasks.jsonl b/sites/webmd_doctor/tasks.jsonl index 1a8b5883..e804328f 100644 --- a/sites/webmd_doctor/tasks.jsonl +++ b/sites/webmd_doctor/tasks.jsonl @@ -1,20 +1,20 @@ -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--0", "ques": "Search for Dermatologists near Newark, DE 19711 and open the profile of Dr. Jonah Dimitriou. Report the medical school Dr. Dimitriou graduated from and the graduation year.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Dermatologist&sids=29244", "verifier_path": "sites/webmd_doctor/verify/verify_0.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: A results page for Dermatologists near Newark, DE 19711 must have been visited and the named doctor's own profile opened (a same-surname doctor is not the named doctor). The answer must include the medical school name and the graduation year as shown in the profile's Education section; a year taken from a residency or fellowship row is wrong. An empty answer, a missing profile visit, or a fact not associated with this doctor = FAIL."} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--1", "ques": "Find Dr. Julian Zamora, a Cardiovascular Disease specialist whose primary office is in Wilmington, DE. Report the NPI number shown on the profile and the languages spoken.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Cardiologist&sids=29236", "verifier_path": "sites/webmd_doctor/verify/verify_1.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The profile of the Wilmington, DE cardiologist with exactly this name must be opened (a same-surname doctor in another city does not count). The answer must include the 10-digit NPI exactly as printed on the profile and every language listed under Languages. A partial language list, an NPI not visible on the opened profile, or an empty answer = FAIL."} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--2", "ques": "Search for Family Medicine doctors near Newark, DE 19711 and open Dr. Ruth Thackeray's profile. Report the phone number listed for the primary office and that office's Saturday hours.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Family+Medicine", "verifier_path": "sites/webmd_doctor/verify/verify_2.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: A Family Medicine results page near Newark, DE 19711 and the named doctor's profile must both be visited. The answer must include the primary office phone number (digits must match what the profile shows) and both endpoints of that office's Saturday opening window from the hours table. Weekday hours, 'Closed' when the office is open, a phone from another office, or an empty answer = FAIL."} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--3", "ques": "Open the profile of Dr. Mateo Alvarado, a Neurologist in West Chester, PA. Besides the primary office, the Locations section lists one other office. Report that office's name and street address.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/neurology/pennsylvania/west-chester", "verifier_path": "sites/webmd_doctor/verify/verify_3.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The named neurologist's profile must be opened. The answer must name the office in the Locations section that is NOT the primary office and give its street address (number and street). Reporting the primary office, an address from a practice page, only a city, or an empty answer = FAIL."} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--4", "ques": "Find Dr. Charles Villanueva, an Orthopedic Surgeon in Elkton, MD. From the Certifications, License, & Education section, report the board that certified them, the certification year, and the institution where they completed their residency.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/orthopedic-surgery/maryland/elkton", "verifier_path": "sites/webmd_doctor/verify/verify_4.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The named orthopedic surgeon's profile must be opened. The answer must include the certifying board's name, the certification year and the residency institution, all read from the Certifications, License, & Education section. A fellowship or medical-school row substituted for the residency, a year taken from another row, or an empty answer = FAIL."} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--5", "ques": "Search for Gastroenterologists near Newark, DE 19711 and open Dr. Caroline Danforth's profile. Among the five most-treated conditions shown, exactly one is marked \"More Than Most\". Which condition is it, and which condition is listed first under \"View Top 20\"?", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Gastroenterologist", "verifier_path": "sites/webmd_doctor/verify/verify_5.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: A Gastroenterologist results page near Newark, DE 19711 and the named doctor's profile must both be visited. The answer must name the single condition marked 'More Than Most' among the five conditions shown, and the condition listed first after expanding 'View Top 20'. The 'More Often' tier is not 'More Than Most'; giving only one of the two facts, or an empty answer, = FAIL."} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--6", "ques": "Open the profile of Dr. Fatima Jensen, a Psychiatrist in Media, PA, and read all of their reviews. What is the date shown on the oldest review, and what star rating did that reviewer give?", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/psychiatry/pennsylvania/media", "verifier_path": "sites/webmd_doctor/verify/verify_6.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The named psychiatrist's profile must be opened and every review page read (the review list is paginated; the last page must be visited). The answer must give the date printed on the oldest review and that review's star rating. The date of the newest or featured review, a rating without a date or a date without a rating, or an empty answer = FAIL."} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--7", "ques": "Find Dr. Lillian Acosta, an Obstetrics & Gynecology specialist in Salem, NJ. Which of the seven Patients' Perspective criteria received the most needs-improvement votes, and what average wait time is shown on the profile?", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/obstetrics-gynecology/new-jersey/salem", "verifier_path": "sites/webmd_doctor/verify/verify_7.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The named OBGYN's profile must be opened. The answer must name the Patients' Perspective criterion with the largest Needs Improvement count (the number in parentheses, not the percentage bar) and the Average Wait Time value in minutes shown on the profile. A criterion chosen by its Did Well count, a wait time from another profile, or an empty answer = FAIL."} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--8", "ques": "Log in as alice.j@test.com (password: TestPass123!) and open Saved Providers. Exactly one of your saved providers is a Dermatologist. Open that profile and report the institution where they completed their residency, then remove that provider from your saved list.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/", "verifier_path": "sites/webmd_doctor/verify/verify_8.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The login step with the stated e-mail, the Saved Providers page, the opened profile of the one saved Dermatologist, and evidence of the removal (the list without that provider, or the provider's un-saved state) must all be visible, in that order. The answer must include the residency institution from that profile. Removing a different provider, removing nothing, reporting a fellowship or medical school instead of the residency, or an empty answer = FAIL."} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--9", "ques": "Search for Dermatologists near Newark, DE 19711 who are female, accept new patients and accept Blue Cross Blue Shield. Among the results, open the profile of the doctor with fewer than 5 years of experience and report their medical school and the year of their board certification.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Dermatologist&sids=29244", "verifier_path": "sites/webmd_doctor/verify/verify_9.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: A Dermatologist results page near Newark, DE 19711 with the Female, Accepts New Patients and named-insurer filters all applied together must be visible. The profile opened must be the one whose card shows fewer than 5 years of experience. The answer must include the medical school and the board-certification year from the Certifications section (not the graduation year). Filters applied one at a time but never combined, a profile with 5 or more years of experience, or an empty answer = FAIL."} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--10", "ques": "Find Psychiatrists near Newark, DE 19711 who accept Medicaid and have a rating of 4 stars or higher. Open the profile of the one who offers virtual visits and report the average wait time and the residency institution listed.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Psychiatrist", "verifier_path": "sites/webmd_doctor/verify/verify_10.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: A Psychiatrist results page near Newark, DE 19711 with Accepts Medicaid and the 4-stars-and-up rating filter applied together must be visible. The profile opened must be the one card in that list that shows telehealth / virtual visits. The answer must include the Average Wait Time in minutes and the residency institution. Reporting the medical school instead of the residency, a filter never applied, or an empty answer = FAIL."} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--11", "ques": "Set the distance to 10 miles from Newark, DE 19711, search for Family Medicine doctors and sort by Number of Ratings. Open the profile of the doctor with the second-highest number of ratings and report their NPI number and the institution where they completed their residency.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Family+Medicine", "verifier_path": "sites/webmd_doctor/verify/verify_11.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: A Family Medicine results page near Newark, DE 19711 with the distance set to 10 miles and the sort set to Number of Ratings, both applied, must be visible. The profile opened must be the second card of that sorted list. The answer must include the 10-digit NPI and the residency institution from that profile. The top card, an unsorted or default-distance list, a fellowship reported as the residency, or an empty answer = FAIL."} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--12", "ques": "From the Find Providers by Specialty menu open Cardiovascular Disease, then Pennsylvania, then West Chester. Filter to doctors rated 4 stars or higher. Open the profile of the only male doctor in that list and report his fellowship institution and the year he completed it.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/cardiovascular-disease/pennsylvania/west-chester", "verifier_path": "sites/webmd_doctor/verify/verify_12.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The Cardiovascular Disease specialty page, its Pennsylvania page and the West Chester page must be visited in that order via the Find Providers by Specialty menu path, and the West Chester list must then be filtered to 4 stars & up. The profile opened must be the only male doctor remaining in that filtered list. The answer must include the fellowship institution and the fellowship completion year (not residency). A profile reached from a search results page instead of this path, an unfiltered list, or an empty answer = FAIL."} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--13", "ques": "Among Dermatologists in Wilmington, DE, Dr. Gregory Greenwood and Dr. Emerson Huang both accept Blue Cross Blue Shield. Which of the two graduated from medical school earlier? Report that doctor's name and graduation year.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/dermatology/delaware/wilmington", "verifier_path": "sites/webmd_doctor/verify/verify_13.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: Both named profiles must be opened (the graduation year is only shown on a profile). The answer must name the doctor who graduated from medical school earlier, with first and last name, and give that doctor's graduation year. Naming the other doctor, giving the later year, a surname alone, or an empty answer = FAIL."} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--14", "ques": "Open the hospital page for Christina Creek Medical Center (Find a Facility > Hospitals > Delaware). Two of its listed physicians are Psychiatrists; open both profiles. Which one was board certified more recently? Report that doctor's name and the certification year.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/hospitals/delaware", "verifier_path": "sites/webmd_doctor/verify/verify_14.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The Delaware hospitals list and the named hospital's page must be visited, then both Psychiatrist profiles listed on that hospital page opened. The answer must name the doctor whose board-certification year is more recent, with first and last name, and give that certification year. Naming the other doctor, a graduation year in place of the certification year, profiles of a different specialty, profiles reached without the hospital page, or an empty answer = FAIL."} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--15", "ques": "From the header, open Award Winning Hospitals, then view the WebMD Patient's Choice recipients. Find the recipient who practices Orthopedic Surgery in Media, PA, open their profile, then open the practice page linked from their primary office. Report the practice's website address and its Saturday hours.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/choice-awards/awardrecipients?award-class=patient", "verifier_path": "sites/webmd_doctor/verify/verify_15.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The Award Winning Hospitals page, the Patient's Choice recipients list, the matching recipient's profile and the practice page linked from that profile's primary office must all be visited, in that order. The answer must include the practice's website address exactly as printed (including its domain ending) and both endpoints of the practice's Saturday hours. Hours copied from the doctor profile without opening the practice page, another award class's list, or an empty answer = FAIL."} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--16", "ques": "Log in with the demo account (email: bob.c@test.com, password: TestPass123!), search for Pediatricians near Newark, DE 19711, open the profile of Dr. Anita Castellano and save the provider. Then open Saved Providers and confirm Dr. Castellano appears there.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Pediatrician", "verifier_path": "sites/webmd_doctor/verify/verify_16.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: Login with the stated e-mail, a Pediatrician results page near Newark, DE 19711, the named doctor's profile with the save action (the button changes to Saved Provider or a saved confirmation appears), and then the Saved Providers page listing that doctor must all be visible, in this order. A Saved Providers page opened before the save does not count as confirmation. An answer claiming success without the list showing the doctor, or an empty answer, = FAIL."} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--17", "ques": "Log in as carol.d@test.com (password: TestPass123!). Open the profile of Dr. Sarah Keller, a Cardiovascular Disease specialist in Newark, DE, and request an appointment as a New Patient at the Riverfront Heart & Vascular - Wellness Center office on Mon, Sep 14 at 10:30 AM. Report the confirmation reference shown after submitting.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Cardiologist&sids=29236", "verifier_path": "sites/webmd_doctor/verify/verify_17.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: Login with the stated e-mail, the named cardiologist's profile, the appointment request page and a confirmation page showing a confirmation reference must be visible, in that order. The request shown on the confirmation page must be at the office named in the task, as a New Patient, on the stated date and time. The answer must include the reference exactly as displayed on the confirmation page; a reference that is not visible there, a request at a different office, patient type or time, or an empty answer = FAIL. The deterministic verifier owns the database match."} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--18", "ques": "Log in as david.k@test.com (password: TestPass123!). Find Dr. Tariq Huang, a Dermatologist in Elkton, MD, and leave a 4-star review with the text \"Short wait and a clear explanation of my treatment options.\" Confirm the profile now shows your review as Pending review.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/reviews-guidelines", "verifier_path": "sites/webmd_doctor/verify/verify_18.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: Login with the stated e-mail and the named Elkton dermatologist's profile must be visible. The review form must be submitted with exactly 4 stars and the quoted text verbatim, and after submitting the profile must show the new review marked Pending review (the run must end on that profile). A different star count, altered text, a review left on a same-surname doctor, no visible pending review, or an empty answer = FAIL."} -{"web_name": "WebMD Doctor", "id": "WebMD Doctor--19", "ques": "Register a new account with an email and password of your choice. Then search for Neurologists near Newark, DE 19711 who offer virtual visits, open the profile of Dr. Monica Carrington, and save the provider. Report the NPI number shown on Dr. Carrington's profile.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Neurologist", "verifier_path": "sites/webmd_doctor/verify/verify_19.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: A completed registration (the Sign Up page followed by a signed-in header), a Neurologist results page near Newark, DE 19711 with the Virtual Visit filter applied, the named doctor's profile and its save action must all be visible. The answer must include the 10-digit NPI shown on that profile. Logging in with a pre-existing demo account instead of registering, a results page without the virtual-visit filter, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--0", "ques": "Search for Dermatologists near Newark, DE 19711 and open the profile of Dr. Jonah Dimitriou. Report the medical school Dr. Dimitriou graduated from and the graduation year.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Dermatologist&sids=29244", "verifier_path": "sites/webmd_doctor/verify/verify_0.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the screenshots cover only the last few steps, so a page, section or list that is absent from them is neither confirmed nor contradicted and the step list decides; the agent's final answer text is the evidence for the facts it reports (no extracted_content is required); when no checkpoint is contradicted, success must be true and the rationale must not conclude the opposite; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: A results page for Dermatologists near Newark, DE 19711 must have been visited and the named doctor's own profile opened (a same-surname doctor is not the named doctor). The answer must include the medical school name and the graduation year from the profile's Education section (this section is far down the profile and need not appear in a screenshot); a year taken from a residency or fellowship row is wrong. An empty answer, a missing profile visit, or a fact not associated with this doctor = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--1", "ques": "Find Dr. Julian Zamora, a Cardiovascular Disease specialist whose primary office is in Wilmington, DE. Report the NPI number shown on the profile and the languages spoken.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Cardiologist&sids=29236", "verifier_path": "sites/webmd_doctor/verify/verify_1.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the screenshots cover only the last few steps, so a page, section or list that is absent from them is neither confirmed nor contradicted and the step list decides; the agent's final answer text is the evidence for the facts it reports (no extracted_content is required); when no checkpoint is contradicted, success must be true and the rationale must not conclude the opposite; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The profile of the Wilmington, DE cardiologist with exactly this name must be opened (a same-surname doctor in another city does not count). The answer must include the 10-digit NPI exactly as printed on the profile and every language listed under Languages. A partial language list, an NPI not visible on the opened profile, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--2", "ques": "Search for Family Medicine doctors near Newark, DE 19711 and open Dr. Ruth Thackeray's profile. Report the phone number listed for the primary office and that office's Saturday hours.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Family+Medicine", "verifier_path": "sites/webmd_doctor/verify/verify_2.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the screenshots cover only the last few steps, so a page, section or list that is absent from them is neither confirmed nor contradicted and the step list decides; the agent's final answer text is the evidence for the facts it reports (no extracted_content is required); when no checkpoint is contradicted, success must be true and the rationale must not conclude the opposite; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: A Family Medicine results page near Newark, DE 19711 and the named doctor's profile must both be visited. The answer must include the primary office phone number (digits must match what the profile shows) and both endpoints of that office's Saturday opening window from the hours table. Weekday hours, 'Closed' when the office is open, a phone from another office, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--3", "ques": "Open the profile of Dr. Mateo Alvarado, a Neurologist in West Chester, PA. Besides the primary office, the Locations section lists one other office. Report that office's name and street address.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/neurology/pennsylvania/west-chester", "verifier_path": "sites/webmd_doctor/verify/verify_3.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the screenshots cover only the last few steps, so a page, section or list that is absent from them is neither confirmed nor contradicted and the step list decides; the agent's final answer text is the evidence for the facts it reports (no extracted_content is required); when no checkpoint is contradicted, success must be true and the rationale must not conclude the opposite; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The named neurologist's profile must be opened. The answer must name the office in the Locations section that is NOT the primary office and give its street address (number and street). Reporting the primary office, an address from a practice page, only a city, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--4", "ques": "Find Dr. Charles Villanueva, an Orthopedic Surgeon in Elkton, MD. From the Certifications, License, & Education section, report the board that certified them, the certification year, and the institution where they completed their residency.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/orthopedic-surgery/maryland/elkton", "verifier_path": "sites/webmd_doctor/verify/verify_4.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the screenshots cover only the last few steps, so a page, section or list that is absent from them is neither confirmed nor contradicted and the step list decides; the agent's final answer text is the evidence for the facts it reports (no extracted_content is required); when no checkpoint is contradicted, success must be true and the rationale must not conclude the opposite; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The named orthopedic surgeon's profile must be opened. The answer must include the certifying board's name, the certification year and the residency institution, from the Certifications, License, & Education section (this section is far down the profile and need not appear in a screenshot). A fellowship or medical-school row substituted for the residency, a year taken from another row, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--5", "ques": "Search for Gastroenterologists near Newark, DE 19711 and open Dr. Caroline Danforth's profile. Among the five most-treated conditions shown, exactly one is marked \"More Than Most\". Which condition is it, and which condition is listed first under \"View Top 20\"?", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Gastroenterologist", "verifier_path": "sites/webmd_doctor/verify/verify_5.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the screenshots cover only the last few steps, so a page, section or list that is absent from them is neither confirmed nor contradicted and the step list decides; the agent's final answer text is the evidence for the facts it reports (no extracted_content is required); when no checkpoint is contradicted, success must be true and the rationale must not conclude the opposite; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: A Gastroenterologist results page near Newark, DE 19711 and the named doctor's profile must both be visited. The answer must name the single condition marked 'More Than Most' among the five conditions shown, and the condition listed first once 'View Top 20' is expanded (a click on that control in the step list is enough; the expanded list need not appear in a screenshot). The 'More Often' tier is not 'More Than Most'; giving only one of the two facts, or an empty answer, = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--6", "ques": "Open the profile of Dr. Fatima Jensen, a Psychiatrist in Media, PA, and read all of their reviews. What is the date shown on the oldest review, and what star rating did that reviewer give?", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/psychiatry/pennsylvania/media", "verifier_path": "sites/webmd_doctor/verify/verify_6.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the screenshots cover only the last few steps, so a page, section or list that is absent from them is neither confirmed nor contradicted and the step list decides; the agent's final answer text is the evidence for the facts it reports (no extracted_content is required); when no checkpoint is contradicted, success must be true and the rationale must not conclude the opposite; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The named psychiatrist's profile must be opened and every review page read (the review list is paginated; the last page must be visited). The answer must give the date printed on the oldest review and that review's star rating. The date of the newest or featured review, a rating without a date or a date without a rating, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--7", "ques": "Find Dr. Lillian Acosta, an Obstetrics & Gynecology specialist in Salem, NJ. Which of the seven Patients' Perspective criteria received the most needs-improvement votes, and what average wait time is shown on the profile?", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/obstetrics-gynecology/new-jersey/salem", "verifier_path": "sites/webmd_doctor/verify/verify_7.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the screenshots cover only the last few steps, so a page, section or list that is absent from them is neither confirmed nor contradicted and the step list decides; the agent's final answer text is the evidence for the facts it reports (no extracted_content is required); when no checkpoint is contradicted, success must be true and the rationale must not conclude the opposite; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The named OBGYN's profile must be opened. The answer must name the Patients' Perspective criterion with the largest Needs Improvement count (the number in parentheses, not the percentage bar) and the Average Wait Time value in minutes shown on the profile. A criterion chosen by its Did Well count, a wait time from another profile, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--8", "ques": "Log in as alice.j@test.com (password: TestPass123!) and open Saved Providers. Exactly one of your saved providers is a Dermatologist. Open that profile and report the institution where they completed their residency, then remove that provider from your saved list.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/", "verifier_path": "sites/webmd_doctor/verify/verify_8.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the screenshots cover only the last few steps, so a page, section or list that is absent from them is neither confirmed nor contradicted and the step list decides; the agent's final answer text is the evidence for the facts it reports (no extracted_content is required); when no checkpoint is contradicted, success must be true and the rationale must not conclude the opposite; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The login step with the stated e-mail, the Saved Providers page, the opened profile of the one saved Dermatologist, and evidence of the removal (the list without that provider, or the provider's un-saved state) must all be evidenced by the step list or a screenshot, in that order. The answer must include the residency institution from that profile. Removing a different provider, removing nothing, reporting a fellowship or medical school instead of the residency, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--9", "ques": "Search for Dermatologists near Newark, DE 19711 who are female, accept new patients and accept Blue Cross Blue Shield. Among the results, open the profile of the doctor with fewer than 5 years of experience and report their medical school and the year of their board certification.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Dermatologist&sids=29244", "verifier_path": "sites/webmd_doctor/verify/verify_9.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the screenshots cover only the last few steps, so a page, section or list that is absent from them is neither confirmed nor contradicted and the step list decides; the agent's final answer text is the evidence for the facts it reports (no extracted_content is required); when no checkpoint is contradicted, success must be true and the rationale must not conclude the opposite; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: A Dermatologist results page near Newark, DE 19711 with the Female, Accepts New Patients and named-insurer filters all applied together must appear in the step list (the results URL carrying those filters, or the filter clicks); the results page need not be in a screenshot. The profile opened must be the one whose card shows fewer than 5 years of experience. The answer must include the medical school and the board-certification year from the Certifications section (this section is far down the profile and need not appear in a screenshot) (not the graduation year). Filters applied one at a time but never combined, a profile with 5 or more years of experience, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--10", "ques": "Find Psychiatrists near Newark, DE 19711 who accept Medicaid and have a rating of 4 stars or higher. Open the profile of the one who offers virtual visits and report the average wait time and the residency institution listed.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Psychiatrist", "verifier_path": "sites/webmd_doctor/verify/verify_10.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the screenshots cover only the last few steps, so a page, section or list that is absent from them is neither confirmed nor contradicted and the step list decides; the agent's final answer text is the evidence for the facts it reports (no extracted_content is required); when no checkpoint is contradicted, success must be true and the rationale must not conclude the opposite; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: A Psychiatrist results page near Newark, DE 19711 with Accepts Medicaid and the 4-stars-and-up rating filter applied together must appear in the step list (the results URL carrying those filters, or the filter clicks); the results page need not be in a screenshot. The profile opened must be the one card in that list that shows telehealth / virtual visits. The answer must include the Average Wait Time in minutes and the residency institution. Reporting the medical school instead of the residency, a filter never applied, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--11", "ques": "Set the distance to 10 miles from Newark, DE 19711, search for Family Medicine doctors and sort by Number of Ratings. Open the profile of the doctor with the second-highest number of ratings and report their NPI number and the institution where they completed their residency.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Family+Medicine", "verifier_path": "sites/webmd_doctor/verify/verify_11.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the screenshots cover only the last few steps, so a page, section or list that is absent from them is neither confirmed nor contradicted and the step list decides; the agent's final answer text is the evidence for the facts it reports (no extracted_content is required); when no checkpoint is contradicted, success must be true and the rationale must not conclude the opposite; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: A Family Medicine results page near Newark, DE 19711 with the distance set to 10 miles and the sort set to Number of Ratings, both applied, must appear in the step list (the results URL carrying those filters, or the filter clicks); the results page need not be in a screenshot. The profile opened must be the second card of that sorted list (judged from the step list, not from a screenshot). The answer must include the 10-digit NPI and the residency institution from that profile. The top card, an unsorted or default-distance list, a fellowship reported as the residency, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--12", "ques": "From the Find Providers by Specialty menu open Cardiovascular Disease, then Pennsylvania, then West Chester. Filter to doctors rated 4 stars or higher. Open the profile of the only male doctor in that list and report his fellowship institution and the year he completed it.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/cardiovascular-disease/pennsylvania/west-chester", "verifier_path": "sites/webmd_doctor/verify/verify_12.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the screenshots cover only the last few steps, so a page, section or list that is absent from them is neither confirmed nor contradicted and the step list decides; the agent's final answer text is the evidence for the facts it reports (no extracted_content is required); when no checkpoint is contradicted, success must be true and the rationale must not conclude the opposite; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The Cardiovascular Disease specialty page, its Pennsylvania page and the West Chester page must be visited in that order via the Find Providers by Specialty menu path, and the West Chester list must then be filtered to 4 stars & up. The profile opened must be the only male doctor remaining in that filtered list. The answer must include the fellowship institution and the fellowship completion year (not residency). A profile reached from a search results page instead of this path, an unfiltered list, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--13", "ques": "Among Dermatologists in Wilmington, DE, Dr. Gregory Greenwood and Dr. Emerson Huang both accept Blue Cross Blue Shield. Which of the two graduated from medical school earlier? Report that doctor's name and graduation year.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/providers/specialty/dermatology/delaware/wilmington", "verifier_path": "sites/webmd_doctor/verify/verify_13.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the screenshots cover only the last few steps, so a page, section or list that is absent from them is neither confirmed nor contradicted and the step list decides; the agent's final answer text is the evidence for the facts it reports (no extracted_content is required); when no checkpoint is contradicted, success must be true and the rationale must not conclude the opposite; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: Both named profiles must be opened (the graduation year is only shown on a profile). The answer must name the doctor who graduated from medical school earlier, with first and last name, and give that doctor's graduation year. Naming the other doctor, giving the later year, a surname alone, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--14", "ques": "Open the hospital page for Christina Creek Medical Center (Find a Facility > Hospitals > Delaware). Two of its listed physicians are Psychiatrists; open both profiles. Which one was board certified more recently? Report that doctor's name and the certification year.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/hospitals/delaware", "verifier_path": "sites/webmd_doctor/verify/verify_14.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the screenshots cover only the last few steps, so a page, section or list that is absent from them is neither confirmed nor contradicted and the step list decides; the agent's final answer text is the evidence for the facts it reports (no extracted_content is required); when no checkpoint is contradicted, success must be true and the rationale must not conclude the opposite; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The Delaware hospitals list and the named hospital's page must be visited, then both Psychiatrist profiles listed on that hospital page opened. The answer must name the doctor whose board-certification year is more recent, with first and last name, and give that certification year. Naming the other doctor, a graduation year in place of the certification year, profiles of a different specialty, profiles reached without the hospital page, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--15", "ques": "From the header, open Award Winning Hospitals, then view the WebMD Patient's Choice recipients. Find the recipient who practices Orthopedic Surgery in Media, PA, open their profile, then open the practice page linked from their primary office. Report the practice's website address and its Saturday hours.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/choice-awards/awardrecipients?award-class=patient", "verifier_path": "sites/webmd_doctor/verify/verify_15.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the screenshots cover only the last few steps, so a page, section or list that is absent from them is neither confirmed nor contradicted and the step list decides; the agent's final answer text is the evidence for the facts it reports (no extracted_content is required); when no checkpoint is contradicted, success must be true and the rationale must not conclude the opposite; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: The Award Winning Hospitals page, the Patient's Choice recipients list, the matching recipient's profile and the practice page linked from that profile's primary office must all be visited, in that order. The answer must include the practice's website address exactly as printed (including its domain ending) and both endpoints of the practice's Saturday hours. Hours copied from the doctor profile without opening the practice page, another award class's list, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--16", "ques": "Log in with the demo account (email: bob.c@test.com, password: TestPass123!), search for Pediatricians near Newark, DE 19711, open the profile of Dr. Anita Castellano and save the provider. Then open Saved Providers and confirm Dr. Castellano appears there.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Pediatrician", "verifier_path": "sites/webmd_doctor/verify/verify_16.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the screenshots cover only the last few steps, so a page, section or list that is absent from them is neither confirmed nor contradicted and the step list decides; the agent's final answer text is the evidence for the facts it reports (no extracted_content is required); when no checkpoint is contradicted, success must be true and the rationale must not conclude the opposite; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: Login with the stated e-mail, a Pediatrician results page near Newark, DE 19711, the named doctor's profile with the save action (the button changes to Saved Provider or a saved confirmation appears), and then the Saved Providers page listing that doctor must all be evidenced by the step list or a screenshot, in this order. A Saved Providers page opened before the save does not count as confirmation. An answer claiming success without the list showing the doctor, or an empty answer, = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--17", "ques": "Log in as carol.d@test.com (password: TestPass123!). Open the profile of Dr. Sarah Keller, a Cardiovascular Disease specialist in Newark, DE, and request an appointment as a New Patient at the Riverfront Heart & Vascular - Wellness Center office on Mon, Sep 14 at 10:30 AM. Report the confirmation reference shown after submitting.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Cardiologist&sids=29236", "verifier_path": "sites/webmd_doctor/verify/verify_17.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the screenshots cover only the last few steps, so a page, section or list that is absent from them is neither confirmed nor contradicted and the step list decides; the agent's final answer text is the evidence for the facts it reports (no extracted_content is required); when no checkpoint is contradicted, success must be true and the rationale must not conclude the opposite; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: Login with the stated e-mail, the named cardiologist's profile, the appointment request page and a confirmation page showing a confirmation reference must be evidenced by the step list or a screenshot, in that order. The request shown on the confirmation page must be at the office named in the task, as a New Patient, on the stated date and time. The answer must include the reference exactly as displayed on the confirmation page; a reference that is not visible there, a request at a different office, patient type or time, or an empty answer = FAIL. The deterministic verifier owns the database match."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--18", "ques": "Log in as david.k@test.com (password: TestPass123!). Find Dr. Tariq Huang, a Dermatologist in Elkton, MD, and leave a 4-star review with the text \"Short wait and a clear explanation of my treatment options.\" Confirm the profile now shows your review as Pending review.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/reviews-guidelines", "verifier_path": "sites/webmd_doctor/verify/verify_18.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the screenshots cover only the last few steps, so a page, section or list that is absent from them is neither confirmed nor contradicted and the step list decides; the agent's final answer text is the evidence for the facts it reports (no extracted_content is required); when no checkpoint is contradicted, success must be true and the rationale must not conclude the opposite; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: Login with the stated e-mail and the named Elkton dermatologist's profile must be evidenced by the step list or a screenshot. The review form must be submitted with exactly 4 stars and the quoted text verbatim, and after submitting the profile must show the new review marked Pending review (the run must end on that profile). A different star count, altered text, a review left on a same-surname doctor, no visible pending review, or an empty answer = FAIL."} +{"web_name": "WebMD Doctor", "id": "WebMD Doctor--19", "ques": "Register a new account with an email and password of your choice. Then search for Neurologists near Newark, DE 19711 who offer virtual visits, open the profile of Dr. Monica Carrington, and save the provider. Report the NPI number shown on Dr. Carrington's profile.", "web": "http://localhost:40024/", "upstream_url": "https://doctor.webmd.com/results?q=Neurologist", "verifier_path": "sites/webmd_doctor/verify/verify_19.py", "judge_rubric": "Scoring rules: the recorded step list is authoritative; a checkpoint is true unless positively contradicted; 'not visible / below the fold / truncated' is not a contradiction; the screenshots cover only the last few steps, so a page, section or list that is absent from them is neither confirmed nor contradicted and the step list decides; the agent's final answer text is the evidence for the facts it reports (no extracted_content is required); when no checkpoint is contradicted, success must be true and the rationale must not conclude the opposite; the deterministic verifier owns exact values and the database state, so do not add requirements absent from the task. Checkpoints: A completed registration (the Sign Up page followed by a signed-in header), a Neurologist results page near Newark, DE 19711 with the Virtual Visit filter applied, the named doctor's profile and its save action must all be evidenced by the step list or a screenshot. The answer must include the 10-digit NPI shown on that profile. Logging in with a pre-existing demo account instead of registering, a results page without the virtual-visit filter, or an empty answer = FAIL."} diff --git a/sites/webmd_doctor/verify/README.md b/sites/webmd_doctor/verify/README.md index 7c0b2baf..6dbe8270 100644 --- a/sites/webmd_doctor/verify/README.md +++ b/sites/webmd_doctor/verify/README.md @@ -31,7 +31,7 @@ Answer matchers are negation-aware whole-token matches: institution and office n ## Validation (LLM-free matrix) -Every verifier was exercised end to end through `agent_demo/eval_judge.py --run_dir --verifier True` on scripted Playwright runs that reproduce the `agent_demo/agent.py` run signature (trajectory with url-before-action steps, typed text in `input` params, PNG screenshots, `initial.db` copied after `POST /reset/webmd_doctor`, `after.db` copied when the run ends). Rows: no-op (20 tasks), genuine click-walk (20), knowledge shortcut with the correct answer but no profile or route visit (20), genuine path with one decoy fact (18), stateful path with the write skipped (5), genuine path plus a collateral write in another runtime table (5), genuine path plus a duplicate write in the same table (5), read-only task with one incidental save while logged in (15), and the mandated route bypassed (8). 116 cells; every genuine run passes and every other cell fails on the intended check (first failing check recorded per cell). The matrix exposed one verifier defect, fixed before this commit: the stateful verifiers ran the row-identity or `exactly_one_*` count check before `
    _exact_delta`, so "nothing written" surfaced under the wrong name; the delta check now comes first and the redundant count checks were removed. A run with no snapshots and an unreachable container exits 1 with `infra_error: true`; `POST /reset/webmd_doctor` after the whole matrix restores the byte-identical seed. No LLM is involved anywhere in the matrix. +Every verifier was exercised end to end through `agent_demo/eval_judge.py --run_dir --verifier True` on scripted Playwright runs that reproduce the `agent_demo/agent.py` run signature (trajectory with url-before-action steps, typed text in `input` params, PNG screenshots, `initial.db` copied after `POST /reset/webmd_doctor`, `after.db` copied when the run ends). Rows: no-op (20 tasks), genuine click-walk (20), knowledge shortcut with the correct answer but no profile or route visit (20), genuine path with one decoy fact (18), stateful path with the write skipped (5), genuine path plus a collateral write in another runtime table (5), genuine path plus a duplicate write in the same table (5), read-only task with one incidental save while logged in (15), and the mandated route bypassed (8). 116 cells; every genuine run passes and every other cell fails on the intended check (first failing check recorded per cell). The matrix exposed one verifier defect, fixed before this commit: the stateful verifiers ran the row-identity or `exactly_one_*` count check before `
    _exact_delta`, so "nothing written" surfaced under the wrong name; the delta check now comes first and the redundant count checks were removed. A run with no snapshots and an unreachable container exits 1 with `infra_error: true`; `POST /reset/webmd_doctor` after the whole matrix restores the byte-identical seed. No LLM is involved anywhere in the matrix. Real agent runs (`agent_demo/agent.py`, one attempt per task, gpt-5.4-nano and gpt-5.4-mini, 40 runs) were then graded by every verifier and by the rubric-driven LLM judge: verifier PASS 1/20 (nano) and 7/20 (mini), 0 verifier defects, 0 judge false PASSes, verifier/judge agreement 19/20 and 15/20 after the rubric preamble was rewritten from those runs (the residual divergences are judge evidence-window limits). ## Tests From b87db8f7c10ecd3e54c294c1f3a2e9c668b662a8 Mon Sep 17 00:00:00 2001 From: ChilleD Date: Fri, 11 Sep 2026 01:15:04 -0700 Subject: [PATCH 18/21] Harden the WebMD Doctor mirror after full-stack review Seed and data integrity: - All 226 doctor NPIs are now checksum-valid (CMS Luhn rule over 80840+9 digits) and verified unassigned against the NPPES registry (August 2026 monthly + weeklies through 2026-09-06 + deactivated report); the verified list is embedded in seed_data.py and re-checked on every build. RNG stream preserved, so slugs, images and education rows are unchanged. - Alice holds six saved providers (seven rows total) so the saved-provider removal task has real breadth; _seed_is_complete hardened to all immutable counts + foreign-key check. - Booking confirmation references use range-checked deterministic packing. App hardening: - SECRET_KEY from env or per-process random; session cookie hardening; login timing equalization via a dummy scrypt hash; bounded integer/argument parsing; safe_next rejects control characters; IntegrityError and duplicate guards on save/booking/review; booking enforces each office's new-patient policy. - Specialty hub pages count primary-OR-secondary specialty (matching results search); hub header totals reflect the filtered population; /_health added with legacy /health alias; inject_globals degrades safely so error pages render even when the database fails. Verifier hardening (447 tests): - Clause-level negation matching, first-value parameter binding, signed-in checks bound to the login email+password, minimum screenshot 320x240, booking reference recomputation, per-task ordering/role/comparison checks, and an EXPECTED_FACTS cross-check in ground truth covering all 20 tasks. UI, accessibility, responsive: - Sitewide mirror notice and synthetic-data disclaimers; heading-order fixes; skip link; focus-visible outlines everywhere; fieldset/legend radio groups; role=alert error lists; aria-controls/expanded on disclosures; contrast fixes for muted/red tokens, booking steps and map placeholder; 320px overflow fixes (scrollable header nav row, single-column mini grids, wrapping footer badges); no-JS progressive enhancement (menus/popovers render as static lists, noscript submit fallbacks). Asset governance and repo integration: - generated_asset_inventory.json (317 PNGs with sha256) + site-local check_generated_assets.py, gated in the Dockerfile and check_assets.sh. - fetch_assets.sh all-sites mode now enumerates local sites and tolerates extra archives on HF (drugs_com, fedex). - .assets-revision pinned to merged HF main sha ad6f424f72cada9e6f5c09a580 93d0ceeab9c52b (HF assets PR merged); walmart pin assertion updated. - Root README site count 23 -> 25; site README documents the NPI policy, known deviations and benchmark accounts. Tests: new sites/webmd_doctor/tests (seed quality + determinism + Luhn audit, task breadth, rendered-page sweep, generated assets); verify suite 447 passed; cross-site battery green (compass 94, RT 56, walmart 51, ikea 119, walmart verify 246); 20/20 Playwright E2E tasks pass against the packaged image. --- .assets-revision | 2 +- Dockerfile | 5 +- README.md | 2 +- scripts/check_assets.sh | 3 + scripts/fetch_assets.sh | 47 +- .../walmart_careers/tests/test_integration.py | 2 +- sites/webmd_doctor/README.md | 21 +- sites/webmd_doctor/app.py | 169 +- sites/webmd_doctor/check_generated_assets.py | 71 + .../generated_asset_inventory.json | 2227 +++++++++++++++++ sites/webmd_doctor/seed_data.py | 131 +- sites/webmd_doctor/static/css/site.css | 107 +- sites/webmd_doctor/static/js/site.js | 41 +- sites/webmd_doctor/templates/404.html | 1 + sites/webmd_doctor/templates/500.html | 1 + sites/webmd_doctor/templates/_filter_bar.html | 12 +- sites/webmd_doctor/templates/_footer.html | 14 +- .../templates/_physician_card.html | 2 +- .../templates/account_appointments.html | 5 +- .../webmd_doctor/templates/account_saved.html | 1 + .../templates/award_recipients.html | 6 +- sites/webmd_doctor/templates/base.html | 5 +- sites/webmd_doctor/templates/book.html | 18 +- sites/webmd_doctor/templates/doctor.html | 45 +- sites/webmd_doctor/templates/guidelines.html | 5 +- sites/webmd_doctor/templates/hospital.html | 3 +- sites/webmd_doctor/templates/hub_list.html | 5 +- sites/webmd_doctor/templates/index.html | 4 +- sites/webmd_doctor/templates/login.html | 7 +- sites/webmd_doctor/templates/practice.html | 6 +- sites/webmd_doctor/templates/signup.html | 7 +- .../templates/specialty_index.html | 2 +- .../tests/test_generated_assets.py | 17 + .../webmd_doctor/tests/test_rendered_pages.py | 121 + sites/webmd_doctor/tests/test_seed_quality.py | 104 + sites/webmd_doctor/tests/test_task_breadth.py | 55 + sites/webmd_doctor/verify/ground_truth.py | 93 + sites/webmd_doctor/verify/tests/_support.py | 46 +- .../verify/tests/test_verify_0.py | 5 + .../verify/tests/test_verify_1.py | 10 +- .../verify/tests/test_verify_10.py | 11 + .../verify/tests/test_verify_11.py | 6 +- .../verify/tests/test_verify_12.py | 6 + .../verify/tests/test_verify_13.py | 12 + .../verify/tests/test_verify_14.py | 5 + .../verify/tests/test_verify_15.py | 14 + .../verify/tests/test_verify_16.py | 7 + .../verify/tests/test_verify_17.py | 25 +- .../verify/tests/test_verify_18.py | 16 +- .../verify/tests/test_verify_19.py | 9 +- .../verify/tests/test_verify_2.py | 5 + .../verify/tests/test_verify_5.py | 9 + .../verify/tests/test_verify_6.py | 5 + .../verify/tests/test_verify_8.py | 21 + .../verify/tests/test_verify_lib.py | 56 +- sites/webmd_doctor/verify/verify_0.py | 6 + sites/webmd_doctor/verify/verify_1.py | 2 +- sites/webmd_doctor/verify/verify_10.py | 10 +- sites/webmd_doctor/verify/verify_11.py | 11 +- sites/webmd_doctor/verify/verify_12.py | 6 + sites/webmd_doctor/verify/verify_13.py | 6 + sites/webmd_doctor/verify/verify_14.py | 6 + sites/webmd_doctor/verify/verify_15.py | 26 + sites/webmd_doctor/verify/verify_16.py | 5 + sites/webmd_doctor/verify/verify_17.py | 16 + sites/webmd_doctor/verify/verify_19.py | 11 +- sites/webmd_doctor/verify/verify_2.py | 12 + sites/webmd_doctor/verify/verify_5.py | 16 + sites/webmd_doctor/verify/verify_6.py | 6 + sites/webmd_doctor/verify/verify_8.py | 26 + sites/webmd_doctor/verify/verify_9.py | 10 + sites/webmd_doctor/verify/verify_lib.py | 276 +- 72 files changed, 3896 insertions(+), 190 deletions(-) create mode 100644 sites/webmd_doctor/check_generated_assets.py create mode 100644 sites/webmd_doctor/generated_asset_inventory.json create mode 100644 sites/webmd_doctor/tests/test_generated_assets.py create mode 100644 sites/webmd_doctor/tests/test_rendered_pages.py create mode 100644 sites/webmd_doctor/tests/test_seed_quality.py create mode 100644 sites/webmd_doctor/tests/test_task_breadth.py diff --git a/.assets-revision b/.assets-revision index 361a0c3c..6a4563c5 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: refs/pr/68 +revision: ad6f424f72cada9e6f5c09a58093d0ceeab9c52b diff --git a/Dockerfile b/Dockerfile index d93df3e3..823f2d23 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,8 +52,9 @@ RUN cd /opt/WebSyn/walmart_careers && rm -rf instance instance_seed && \ # WebMD Doctor's generated avatars / posters come from the pinned asset bundle, # while its SQLite seed is rebuilt deterministically from tracked source code. -RUN test -n "$(ls -A /opt/WebSyn/webmd_doctor/static/images/avatars)" && \ - test -n "$(ls -A /opt/WebSyn/webmd_doctor/static/images/posters)" +# The inventory gate enforces exact coverage + per-file SHA-256 + PNG decode of +# all 317 generated images (same contract as the compass / walmart inventories). +RUN python3 /opt/WebSyn/webmd_doctor/check_generated_assets.py RUN cd /opt/WebSyn/webmd_doctor && rm -rf instance instance_seed && \ PYTHONHASHSEED=0 python seed_data.py && rm -rf instance __pycache__ diff --git a/README.md b/README.md index 5fcf1129..b6bbd22b 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ git clone https://github.com/aiming-lab/WebHarbor && cd WebHarbor ## 🤝 Contribute -We have built 23 high-quality mirrors covering the [WebVoyager](https://github.com/MinorJerry/WebVoyager) benchmark. The next goal is **100+ sites**, covering everything in [Online-Mind2Web](https://huggingface.co/datasets/osunlp/Online-Mind2Web). We are inviting the community to build this together. +We have built 25 high-quality mirrors covering the [WebVoyager](https://github.com/MinorJerry/WebVoyager) benchmark. The next goal is **100+ sites**, covering everything in [Online-Mind2Web](https://huggingface.co/datasets/osunlp/Online-Mind2Web). We are inviting the community to build this together. There are two ways to join the author list: diff --git a/scripts/check_assets.sh b/scripts/check_assets.sh index d74cb589..29a68667 100755 --- a/scripts/check_assets.sh +++ b/scripts/check_assets.sh @@ -37,6 +37,9 @@ for site in sites/*/; do if [[ -f "sites/$s/asset_inventory.json" ]]; then python3 scripts/check_asset_inventory.py "sites/$s" fi + if [[ -f "sites/$s/generated_asset_inventory.json" && -f "sites/$s/check_generated_assets.py" ]]; then + python3 "sites/$s/check_generated_assets.py" + fi done if (( missing > 0 )); then diff --git a/scripts/fetch_assets.sh b/scripts/fetch_assets.sh index 7c10f9a8..b7e63779 100755 --- a/scripts/fetch_assets.sh +++ b/scripts/fetch_assets.sh @@ -32,32 +32,43 @@ mkdir -p "$CACHE_DIR" echo "[fetch] huggingface.co/datasets/$REPO @ $REVISION -> sites/" if [[ -n "$ONLY_SITE" ]]; then - INCLUDE="$ONLY_SITE.tar.gz" echo "[fetch] scope: $ONLY_SITE only" + SITES_TO_FETCH=("$ONLY_SITE") else - INCLUDE="*.tar.gz" + # Derive the fetch list from the LOCAL site directories, not from the HF + # tree. The dataset can legitimately hold extra tarballs for sites that are + # not (yet) on this branch (e.g. drugs_com, fedex); globbing *.tar.gz and + # requiring an exact count made all-sites fetch fail on every such revision. + SITES_TO_FETCH=() + for site_dir in sites/*/; do + [[ -d "$site_dir" ]] || continue + SITES_TO_FETCH+=("$(basename "$site_dir")") + done fi +# One explicit --include per local site; unrelated HF tarballs are never pulled. +INCLUDE_ARGS=() +for name in "${SITES_TO_FETCH[@]}"; do + INCLUDE_ARGS+=(--include "$name.tar.gz") +done + hf download "$REPO" --repo-type dataset --revision "$REVISION" \ - --include "$INCLUDE" --local-dir "$CACHE_DIR" + "${INCLUDE_ARGS[@]}" --local-dir "$CACHE_DIR" shopt -s nullglob -if [[ -n "$ONLY_SITE" ]]; then - TARBALLS=("$CACHE_DIR/$ONLY_SITE.tar.gz") - if [[ ! -f "${TARBALLS[0]}" ]]; then - echo "fetch_assets: expected archive for $ONLY_SITE" >&2 - exit 1 - fi -else - TARBALLS=("$CACHE_DIR"/*.tar.gz) - expected=0 - for site_dir in sites/*/; do - [[ -d "$site_dir" ]] && expected=$((expected + 1)) - done - if [[ ${#TARBALLS[@]} -ne $expected ]]; then - echo "fetch_assets: expected $expected site archives at revision $REVISION, found ${#TARBALLS[@]}" >&2 - exit 1 +TARBALLS=() +missing=0 +for name in "${SITES_TO_FETCH[@]}"; do + tarball="$CACHE_DIR/$name.tar.gz" + if [[ -f "$tarball" ]]; then + TARBALLS+=("$tarball") + else + echo "fetch_assets: missing archive for site '$name' at revision $REVISION" >&2 + missing=1 fi +done +if [[ "$missing" -ne 0 ]]; then + exit 1 fi extracted=0 for tarball in "${TARBALLS[@]}"; do diff --git a/sites/walmart_careers/tests/test_integration.py b/sites/walmart_careers/tests/test_integration.py index e01d6c7c..b6714bfc 100644 --- a/sites/walmart_careers/tests/test_integration.py +++ b/sites/walmart_careers/tests/test_integration.py @@ -59,7 +59,7 @@ def test_tasks_and_verifiers_are_complete_and_use_site_24(): def test_assets_pin_is_immutable_merged_revision(): text = (ROOT / ".assets-revision").read_text() revision = re.search(r"^revision:\s*([0-9a-f]+)$", text, re.M).group(1) - assert revision == "65c479f894763f64c6073e0d180ebf542d1d2c02" + assert revision == "ad6f424f72cada9e6f5c09a58093d0ceeab9c52b" assert (SITE / ".build-generated-seed").is_file() assert (SITE / ".requires-images").is_file() assert (SITE / "asset_inventory.json").is_file() diff --git a/sites/webmd_doctor/README.md b/sites/webmd_doctor/README.md index 55411608..ed6763a2 100644 --- a/sites/webmd_doctor/README.md +++ b/sites/webmd_doctor/README.md @@ -24,10 +24,25 @@ The Docker build regenerates `instance_seed/webmd_doctor.db` from `seed_data.py` | cities / city_zips | 8 / 24 | hospitals / practices | 12 / 30 | | reviews | 1227 | doctor_perspectives | 1582 | | certifications / licenses / education | 307 / 295 / 595 | awards / doctor_languages | 50 / 363 | -| users | 4 | saved_providers / appointment_requests / user_reviews | 4 / 1 / 1 | +| users | 4 | saved_providers / appointment_requests / user_reviews | 7 / 1 / 1 | -Benchmark accounts: `alice.j`, `bob.c`, `carol.d`, `david.k` `@test.com`, password `TestPass123!`. +Benchmark accounts: `alice.j`, `bob.c`, `carol.d`, `david.k` `@test.com`, password `TestPass123!` (public by design; the scrypt hashes are hardcoded in `seed_data.py` and validated at build time). Gender distribution: 102 female / 103 male / 21 non-binary. ## Routes -`/`, `/results` (deterministic term parser + conjunctive filters, Best Match / Distance / Average Rating / Number of Ratings), `/doctor/-overview` (tab aliases 301), `/doctor//bookappointment` (Enhanced only, login required), `/doctor//save`, `/doctor//review`, `/providers/specialty[/[/[/]]]`, `/hospitals[/]`, `/hospital/`, `/grouppractices[/]`, `/practice/`, `/choice-awards`, `/choice-awards/awardrecipients?award-class=`, `/reviews-guidelines`, `/login`, `/signup`, `/logout` (POST), `/account/saved`, `/account/saved//remove`, `/account/appointments`, `/health`. +`/`, `/results` (deterministic term parser + conjunctive filters, Best Match / Distance / Average Rating / Number of Ratings), `/doctor/-overview` (tab aliases 301), `/doctor//bookappointment` (Enhanced only, login required), `/doctor//save`, `/doctor//review`, `/providers/specialty[/[/[/]]]`, `/hospitals[/]`, `/hospital/`, `/grouppractices[/]`, `/practice/`, `/choice-awards`, `/choice-awards/awardrecipients?award-class=`, `/reviews-guidelines`, `/login`, `/signup`, `/logout` (POST), `/account/saved`, `/account/saved//remove`, `/account/appointments`, `/_health` (with `/health` kept as a legacy alias). + +## Synthetic identifier policy (NPI) + +The 226 seeded NPIs are benchmark identifiers, not real provider numbers. Each is a 10-digit individual-range value (leading `1`) whose check digit satisfies the CMS rule (Luhn mod 10 over `80840` + the first nine digits), and each was verified as **not assigned** against the NPPES full dissemination file of 2026-08-09, the weekly files through 2026-09-06, and the deactivated-NPI report of 2026-08-10 (9,786,956 unique values). The registry-verified list is embedded in `seed_data.py` (`VERIFIED_NPIS`) and re-checked at every seed build; the site RNG stream is independent of the list, so slugs and images are unaffected. As with any unassigned identifier, a future NPPES assignment could eventually collide; the whole site is labelled synthetic on every page. + +## Known deviations from upstream + +- 226 doctors (above the 200 guideline) so every distance bucket and filter value keeps >= 20 rows within the default 40-mile radius; non-binary gender seeded at 21. +- Pillow-drawn initials avatars and gradient poster panels instead of photography (no real people); all website URLs use the reserved `.example` TLD and are unreachable offline. +- Free-text search for a doctor's NAME returns the upstream-style zero-result state by design (upstream behaves the same); every task routes through specialty, hub, filter or award pages. +- Specialty-by-state and specialty-by-city hub pages are thin (1 to 14 doctors), mirroring upstream's per-city structure. +- Review sorting/filtering/search controls, maps, "List/Claim your practice", password recovery and footer policy links are labelled unavailable in the mirror. +- Menus and filter popovers require JavaScript for the overlay behavior; without JavaScript they render as static expanded lists (progressive enhancement), and select-driven forms expose a `
    +
    {% for row in rows %} {% endfor %} -
    ReferenceProviderLocationPatientRequested time
    {{ row.reference }}{{ row.doctor.display_name }}{{ row.location.name }}
    {{ row.location.short_address }}
    {{ row.patient_type }}{{ row.slot_label }}
    + {% else %}
    You have no appointment requests yet.
    {% endif %} diff --git a/sites/webmd_doctor/templates/account_saved.html b/sites/webmd_doctor/templates/account_saved.html index 49dcca69..5ac9050d 100644 --- a/sites/webmd_doctor/templates/account_saved.html +++ b/sites/webmd_doctor/templates/account_saved.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% block title %}Saved Providers{% endblock %} +{% block meta_description %}Your saved providers on the WebMD Care mirror.{% endblock %} {% block content %}

    Saved Providers

    diff --git a/sites/webmd_doctor/templates/award_recipients.html b/sites/webmd_doctor/templates/award_recipients.html index f018aa68..a43782ee 100644 --- a/sites/webmd_doctor/templates/award_recipients.html +++ b/sites/webmd_doctor/templates/award_recipients.html @@ -3,14 +3,15 @@ {% block content %}
    -

    {{ class_title }} Award 2025–2026

    -

    WebMD Choice Awards let you find the providers recognized by patients and health care professionals in the 2025–2026 cycle. Open a provider's profile for their full details.

    +

    {{ class_title }} Award Recipients

    +

    WebMD Choice Awards let you find the providers recognized by patients and health care professionals. Each card shows its award year. Open a provider's profile for their full details.

    Filters: + {{ award_classes[award_class][2] }} ✕
    @@ -18,6 +19,7 @@

    {{ class_title }} Award 2025– {% for doctor in page.rows %} {% set state_name = doctor.primary_location.city.state_name %} {% if state_name != ns.state %}{% set ns.state = state_name %}

    {{ state_name }}

    {% endif %} + {% set award_year = years.get(doctor.id) %} {% include "_physician_card.html" %} {% else %}
    No recipients match these filters.
    diff --git a/sites/webmd_doctor/templates/base.html b/sites/webmd_doctor/templates/base.html index 30a06ee0..84161c13 100644 --- a/sites/webmd_doctor/templates/base.html +++ b/sites/webmd_doctor/templates/base.html @@ -7,20 +7,23 @@ + + {% include "_icons.html" %} {% include "_header.html" %}
    {% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} -
    + {% endif %} {% endwith %} {% block content %}{% endblock %}
    +
    Unofficial offline benchmark mirror of doctor.webmd.com with synthetic data. Not affiliated with or endorsed by WebMD LLC.
    {% block footer %}{% include "_footer.html" %}{% endblock %} {% block scripts %}{% endblock %} diff --git a/sites/webmd_doctor/templates/book.html b/sites/webmd_doctor/templates/book.html index ea473008..121ef2d9 100644 --- a/sites/webmd_doctor/templates/book.html +++ b/sites/webmd_doctor/templates/book.html @@ -13,17 +13,20 @@
    123
    - {% if errors %}
      {% for error in errors %}
    • {{ error }}
    • {% endfor %}
    {% endif %} -

    This appointment is for:

    -
    - {% for kind in patient_types %}{% endfor %} -
    -

    Location:

    + {% if errors %}{% endif %} +
    + This appointment is for: +
    + {% for kind in patient_types %}{% endfor %} +
    +
    +

    Location:

    -

    Choose a time:

    +
    + Choose a time:
    September 2026
    {% set chosen_slot = form.slot or request.args.get('slot', '') %}
    @@ -37,6 +40,7 @@

    Choose a time:

    {% endfor %}
    +
    diff --git a/sites/webmd_doctor/templates/doctor.html b/sites/webmd_doctor/templates/doctor.html index 0b336e0d..f04504cb 100644 --- a/sites/webmd_doctor/templates/doctor.html +++ b/sites/webmd_doctor/templates/doctor.html @@ -33,7 +33,7 @@

    {{ doctor.display_name }}

  • {{ primary.name }}
    {{ primary.address_line }}{% if doctor.other_location_count %} {{ doctor.other_location_count | plural_word('other location') }}{% endif %}
  • {% if doctor.hospital %}
  • {{ doctor.hospital.name }}
  • {% endif %} {% for line in doctor.award_lines %}
  • {{ line }}
  • {% endfor %} - {% if doctor.website_url %}
  • Visit Website
  • {% endif %} + {% if doctor.website_url %}
  • Visit Website (external, unavailable in mirror)
  • {% endif %}
  • @@ -49,17 +49,21 @@

    {{ doctor.display_name }}

    {% if doctor.is_enhanced %}
    @@ -130,7 +135,7 @@

    Book an Appointment

    {{ location.name }}
    {{ location.street }}
    {{ location.city.name }}, {{ location.city.state }}, {{ location.zip }}
    {{ location.phone }} - +
    {% for row in location.hours_rows() %}
    {{ row.day }}
    {{ row.text }}
    {% endfor %}
  • Map unavailable in mirror
    @@ -181,15 +186,15 @@

    Patients' Perspective

    Overall provider rating* -
    - +
    + How did {{ doctor.short_name }} do?
    {% for label in perspective_criteria %} {% set index = loop.index %}
    {{ label }}
    {% endfor %}
    -
    +
    @@ -201,7 +206,7 @@

    Patients' Perspective

    {% endif %} -

    {{ doctor.text_review_count }} REVIEWS

    Most Recent FilterSearch
    +

    {{ doctor.text_review_count }} REVIEWS

    Showing {{ reviews_page.start }}-{{ reviews_page.end }} of {{ reviews_page.total }} reviews
    {% for review in reviews_page.rows %}
    @@ -212,7 +217,7 @@

    Patients' Perspective

    Wait time: {{ review.wait_bucket }}
    {{ review.date_label }}
    -
    Helpful{% if review.helpful_count %} ({{ review.helpful_count }}){% endif %}Flag
    +
    Helpful{% if review.helpful_count %} ({{ review.helpful_count }}){% endif %}Flag
    {% else %}

    No written reviews yet.

    @@ -305,16 +310,16 @@

    Patients' Perspective

    Certifications, License, & Education

    {{ doctor.full_name }} holds an active medical license in the state of {{ doctor.licenses[0].state }}. They are board certified in {{ doctor.certifications | map(attribute='cert_type') | join(' and ') }} by the {{ doctor.certifications[0].issuer }}.

    -

    Board Certifications

    +

    Board Certifications

    {% for cert in doctor.certifications %}

    Board certified in {{ cert.cert_type }} by the {{ cert.issuer }} in {{ cert.year }}.

    {% endfor %} -

    Medical License

    +

    Medical License

    {% for lic in doctor.licenses %}{{ lic.license_type }} with an {{ lic.status | lower }} medical license in the state of {{ lic.state }} that expires on {{ lic.expiry_date.strftime('%B') }} {{ lic.expiry_date.day }}, {{ lic.expiry_date.year }}{{ ' and ' if not loop.last else '.' }}{% endfor %}

    -

    Education & Training

    - {% if fellowships %}
    FELLOWSHIP

    Completed their fellowship at {% for row in fellowships %}{{ row.institution }} in {{ row.year }}{{ ' and ' if not loop.last else '.' }}{% endfor %}

    {% endif %} - {% if residencies %}
    RESIDENCY

    Completed their residency at {% for row in residencies %}{{ row.institution }} in {{ row.year }}{{ ' and ' if not loop.last else '.' }}{% endfor %}

    {% endif %} -
    MEDICAL SCHOOL
    +

    Education & Training

    + {% if fellowships %}

    FELLOWSHIP

    Completed their fellowship at {% for row in fellowships %}{{ row.institution }} in {{ row.year }}{{ ' and ' if not loop.last else '.' }}{% endfor %}

    {% endif %} + {% if residencies %}

    RESIDENCY

    Completed their residency at {% for row in residencies %}{{ row.institution }} in {{ row.year }}{{ ' and ' if not loop.last else '.' }}{% endfor %}

    {% endif %} +

    MEDICAL SCHOOL

    Graduated from {{ doctor.medical_school }} in {{ doctor.graduation_year }}.

    -

    NPI Number

    +

    NPI Number

    {{ doctor.short_name }}'s NPI number is {{ doctor.npi }}.

    diff --git a/sites/webmd_doctor/templates/guidelines.html b/sites/webmd_doctor/templates/guidelines.html index 954ffa9e..6794f8bd 100644 --- a/sites/webmd_doctor/templates/guidelines.html +++ b/sites/webmd_doctor/templates/guidelines.html @@ -2,11 +2,12 @@ {% set hide_search_row = true %} {% block footer %}{% endblock %} {% block title %}Reviews Guidelines{% endblock %} +{% block meta_description %}How reviews work on the WebMD Care physician directory mirror.{% endblock %} {% block content %}

    WebMD Reviews Guidelines

    -

    By submitting a review on the WebMD Care physician directory you agree to the Terms and Conditions and to the following review guidelines:

    +

    By submitting a review on the WebMD Care physician directory you agree to the Terms and Conditions and to the following review guidelines:

    1. Your review must describe your own experience with the provider: how the visit went, whether your questions were answered, the wait time and the follow-up. Reviews of a provider you have not seen, or written on behalf of a provider, are not accepted.
    2. Your review must not include personal health information about other people, or the full names of office staff.
    3. @@ -17,7 +18,7 @@

      WebMD Reviews Guidelines

    4. A newly submitted review appears on the provider's profile to its author with the status Pending review until the moderation team checks it; published ratings and counts on a profile do not change until a review is approved.
    5. Providers cannot edit or remove reviews. They may reply to a published review through their claimed profile, and readers' helpful votes and flags are used to prioritise moderation.
    -

    WebMD Care reserves the right, in its sole discretion, to remove reviews that do not meet these guidelines or the terms set forth in the Terms and Conditions.

    +

    WebMD Care reserves the right, in its sole discretion, to remove reviews that do not meet these guidelines or the terms set forth in the Terms and Conditions.

    {% endblock %} diff --git a/sites/webmd_doctor/templates/hospital.html b/sites/webmd_doctor/templates/hospital.html index 8625c5ee..35e311d8 100644 --- a/sites/webmd_doctor/templates/hospital.html +++ b/sites/webmd_doctor/templates/hospital.html @@ -20,6 +20,7 @@

    Select a specialty below to view hospital physicians by specialty. Includes featured providers.

    + {% endif %}
    - +
    {% for key, label in hub_sort_options %}{% endfor %}
    - +
    {% for n in (5, 4, 3, 2, 1) %}{% endfor %}
    +
    diff --git a/sites/webmd_doctor/templates/index.html b/sites/webmd_doctor/templates/index.html index 485c73a7..e9b03e54 100644 --- a/sites/webmd_doctor/templates/index.html +++ b/sites/webmd_doctor/templates/index.html @@ -45,7 +45,7 @@

    Popular specialties

    {% for doctor in top_doctors %}{% with filled=true %}{% include "_mini_card.html" %}{% endwith %}{% endfor %}

    FIND YOUR DOCTOR

    -

    Physicians: Claim Your Profile ›

    +

    Physicians: Claim Your Profile ›

    @@ -72,7 +72,7 @@

    Healthcare specialists for everyone everywhere

    8 million+ Physician Ratings & Reviews
    -

    Find Doctors and Dentists Near You

    +

    Find Doctors and Dentists Near You

    {% with search_bar_id='bottom' %}{% include "_search_bar.html" %}{% endwith %}
    {% for label, href in preset_chips %}{{ label }}{% endfor %} diff --git a/sites/webmd_doctor/templates/login.html b/sites/webmd_doctor/templates/login.html index 0f28c5c7..23c5d53d 100644 --- a/sites/webmd_doctor/templates/login.html +++ b/sites/webmd_doctor/templates/login.html @@ -1,14 +1,15 @@ {% extends "base.html" %} {% set hide_search_row = true %} {% block title %}Log In{% endblock %} +{% block meta_description %}Log in to the WebMD Care mirror to manage saved providers and appointment requests.{% endblock %} {% block content %}
    × -

    Care that starts with a conversation

    Save providers, request appointments and share your experience with other patients.

    +

    Care that starts with a conversation

    Save providers, request appointments and share your experience with other patients.

    Don't have an account? Sign Up

    Log In

    - {% if errors %}
      {% for error in errors %}
    • {{ error }}
    • {% endfor %}
    {% endif %} + {% if errors %}{% endif %}
    {% if next_url %}{% endif %} @@ -16,7 +17,7 @@ - Forgot Password? + Forgot Password?
    diff --git a/sites/webmd_doctor/templates/practice.html b/sites/webmd_doctor/templates/practice.html index ff167f9b..3ae966f1 100644 --- a/sites/webmd_doctor/templates/practice.html +++ b/sites/webmd_doctor/templates/practice.html @@ -3,7 +3,7 @@ {% block content %}
    -

    {{ practice.name }} Claim your practice

    +

    {{ practice.name }} Claim your practice

    {{ specialty_rows | length | plural_word('Specialty', 'Specialties') }}{{ doctors | length | plural_word('Practicing Physician') }}
    {% with rating=practice.avg_rating, small=true %}{% include "_stars.html" %}{% endwith %} ({{ practice.ratings_count }}) | Write A Review
    @@ -40,9 +40,9 @@

    Locations

    Map unavailable in mirror
    {{ practice.name }}
    -
    {{ practice.street }}
    {{ practice.city.name }}, {{ practice.city.state }} {{ practice.zip }}
    Get Directions
    +
    {{ practice.street }}
    {{ practice.city.name }}, {{ practice.city.state }} {{ practice.zip }}
    Get Directions
    - +
    Accepting New Patients: {{ 'Yes' if flags.new_patients else 'No' }}
    Medicare Accepted: {{ 'Yes' if flags.medicare else 'No' }}
    diff --git a/sites/webmd_doctor/templates/signup.html b/sites/webmd_doctor/templates/signup.html index 95c694ad..3c869a76 100644 --- a/sites/webmd_doctor/templates/signup.html +++ b/sites/webmd_doctor/templates/signup.html @@ -1,14 +1,15 @@ {% extends "base.html" %} {% set hide_search_row = true %} {% block title %}Sign Up{% endblock %} +{% block meta_description %}Create an account on the WebMD Care mirror to save providers and request appointments.{% endblock %} {% block content %}
    × -

    Join WebMD Care

    Create a free account to save providers and request appointments.

    +

    Join WebMD Care

    Create a free account to save providers and request appointments.

    Already have an account? Log In

    Sign Up

    - {% if errors %}
      {% for error in errors %}
    • {{ error }}
    • {% endfor %}
    {% endif %} + {% if errors %}{% endif %}
    {% if next_url %}{% endif %} @@ -19,7 +20,7 @@ -

    By signing up, I agree to WebMD Terms of Use & Privacy Policy. I understand that I may opt out of WebMD subscriptions at any time.

    +

    By signing up, I agree to WebMD Terms of Use & Privacy Policy. I understand that I may opt out of WebMD subscriptions at any time.

    diff --git a/sites/webmd_doctor/templates/specialty_index.html b/sites/webmd_doctor/templates/specialty_index.html index 41b9d620..4d92caa3 100644 --- a/sites/webmd_doctor/templates/specialty_index.html +++ b/sites/webmd_doctor/templates/specialty_index.html @@ -7,7 +7,7 @@

    Find Top Doctors for All Specialties

    {% set letters = specialties | map(attribute='name') | map('first') | list %} -
    All{% for letter in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' %}{% if letter in letters %}{{ letter }}{% else %}{{ letter }}{% endif %}{% endfor %}
    +
    All{% for letter in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' %}{% if letter in letters %}{{ letter }}{% else %}{{ letter }}{% endif %}{% endfor %}
    {% for specialty in specialties %}{{ specialty.name }}{% endfor %}
    diff --git a/sites/webmd_doctor/tests/test_generated_assets.py b/sites/webmd_doctor/tests/test_generated_assets.py new file mode 100644 index 00000000..07a3e835 --- /dev/null +++ b/sites/webmd_doctor/tests/test_generated_assets.py @@ -0,0 +1,17 @@ +"""Generated-asset gate for the WebMD Doctor mirror (317 PNGs).""" +from __future__ import annotations + +import sys +from pathlib import Path + +SITE = Path(__file__).resolve().parents[1] + + +def test_generated_asset_inventory_verifies(): + sys.path.insert(0, str(SITE)) + try: + import check_generated_assets + + assert check_generated_assets.verify() == 317 + finally: + sys.path.pop(0) diff --git a/sites/webmd_doctor/tests/test_rendered_pages.py b/sites/webmd_doctor/tests/test_rendered_pages.py new file mode 100644 index 00000000..9167bfe6 --- /dev/null +++ b/sites/webmd_doctor/tests/test_rendered_pages.py @@ -0,0 +1,121 @@ +"""Rendered-page sweep for the WebMD Doctor mirror. + +Renders every content route (all 226 doctor profiles, hospitals, practices, +specialty hubs, awards, auth and account pages) through the Flask test client +and asserts: HTTP 200, the sitewide mirror notice, no unrendered Jinja syntax, +no internal build vocabulary leaks, and the progressive-enhancement hooks. +""" +from __future__ import annotations + +import sqlite3 +import sys +from pathlib import Path + +import pytest + +SITE = Path(__file__).resolve().parents[1] +SEED = SITE / "instance_seed" / "webmd_doctor.db" + +FORBIDDEN_MARKERS = ( + "{{", "{%", "lorem", "TODO:", "VERIFIED_NPIS", "PYTHONHASHSEED", + "instance_seed", "webmd_doctor.db", "seed_data", +) + + +@pytest.fixture(scope="module") +def client(): + sys.path.insert(0, str(SITE)) + try: + import app as app_module + + app_module.app.config["TESTING"] = True + app_module.app.config["WTF_CSRF_ENABLED"] = False + with app_module.app.test_client() as test_client: + yield test_client + finally: + sys.path.pop(0) + + +def _slugs(table: str) -> list[str]: + connection = sqlite3.connect(SEED) + try: + return [row[0] for row in connection.execute(f"SELECT slug FROM {table} ORDER BY id")] + finally: + connection.close() + + +def _assert_clean(response, path: str) -> str: + assert response.status_code == 200, f"{path} -> {response.status_code}" + body = response.get_data(as_text=True) + assert "mirror-notice" in body, f"{path} lacks the mirror notice" + lowered = body.lower() + for marker in FORBIDDEN_MARKERS: + assert marker.lower() not in lowered, f"{path} leaks internal marker {marker!r}" + return body + + +def test_every_content_route_renders_clean(client): + paths = [ + "/", "/results?q=Dermatologist&sids=29244", "/providers/specialty", + "/hospitals", "/hospitals/delaware", "/hospitals/maryland", + "/grouppractices", "/grouppractices/delaware", "/grouppractices/maryland", + "/choice-awards", "/choice-awards/awardrecipients?award-class=elite", + "/choice-awards/awardrecipients?award-class=patient", + "/choice-awards/awardrecipients?award-class=provider", + "/reviews-guidelines", "/login", "/signup", + ] + for spec in _slugs("specialties"): + paths.append(f"/providers/specialty/{spec}") + paths.append(f"/providers/specialty/{spec}/delaware") + for slug in _slugs("doctors"): + paths.append(f"/doctor/{slug}-overview") + for slug in _slugs("hospitals"): + paths.append(f"/hospital/{slug}") + for slug in _slugs("practices"): + paths.append(f"/practice/{slug}") + for path in paths: + _assert_clean(client.get(path), path) + # Health endpoints answer JSON, not chrome. + for path in ("/_health", "/health"): + response = client.get(path) + assert response.status_code == 200, f"{path} -> {response.status_code}" + assert response.get_json()["ok"] is True + + +def test_account_pages_render_clean_when_signed_in(client): + response = client.post("/login", data={ + "email": "alice.j@test.com", "password": "TestPass123!", "csrf_token": ""}) + assert response.status_code == 302 + for path in ("/account/saved", "/account/appointments"): + _assert_clean(client.get(path), path) + + +def test_progressive_enhancement_hooks_present(client): + body = _assert_clean(client.get("/"), "/") + assert 'classList.add("js")' in body, "html.js bootstrapping script missing" + assert "skip-link" in body, "skip link missing" + # Select-driven forms expose a noscript submit fallback. + slug = _slugs("hospitals")[0] + hospital_body = _assert_clean(client.get(f"/hospital/{slug}"), f"/hospital/{slug}") + assert "
    diff --git a/sites/webmd_doctor/tests/test_answer_leaks.py b/sites/webmd_doctor/tests/test_answer_leaks.py new file mode 100644 index 00000000..d3871388 --- /dev/null +++ b/sites/webmd_doctor/tests/test_answer_leaks.py @@ -0,0 +1,243 @@ +"""Answer-leak sweep: task answer facts must not appear on pre-discovery surfaces. + +Ground truth is derived from the frozen seed (ground_truth.all_ground_truth). +Surfaces are every rendered non-profile page: search results (including each +task's own query), specialty hubs, state/city pages, hospital and practice +pages, awards, guidelines, auth pages. Doctor profile pages are the intended +discovery points for profile facts and are excluded from the surface set. + +Value classes and rules (per the review checklist): +- UNIQUE strings (NPIs, phones, institution names, office names/streets, + websites, rendered review dates) must be ABSENT from every surface, except a + task's discovery-point surface (encoded in EXEMPT). +- YEARS are scanned with digit boundaries, except on /choice-awards* surfaces + where award-year chrome lists many co-present years (non-discriminative). +- GENERIC values are excluded from the chrome scan because they collide with + other entities' data on list surfaces: small integers (wait minutes, + ratings, review counts), shared hour strings ("8:00 am"), the seven + Patients' Perspective criterion labels (seed_data reuses them as callout + chips on 89 enhanced doctor cards), and condition names (the index renders + browse-taxonomy cond-tiles and specialty landings list their specialty's + conditions as navigation chips; physician cards render no conditions at + all). Scoring is not weakened: every affected verifier binds the answer to + a required visit of the target profile and matches values derived from the + target entity, and two ENTITY-BOUND tests below assert that the T5 and T7 + targets' own cards never display their answer values. +""" +from __future__ import annotations + +import re +import sqlite3 +import sys +from pathlib import Path + +import pytest + +SITE = Path(__file__).resolve().parents[1] +SEED = SITE / "instance_seed" / "webmd_doctor.db" +sys.path.insert(0, str(SITE / "verify")) +sys.path.insert(0, str(SITE)) + +EXEMPT = {15: set()} +GENERIC_STRINGS = {"8:00 am", "1:00 pm", "9:00 am", "5:00 pm", "noon"} + + +def _facts(): + import ground_truth + return ground_truth.all_ground_truth(str(SEED)) + + +def _scan_values(facts): + out = {} + + def add(n, label, value, kind="str"): + if value in (None, "", []): + return + text = str(value).strip() + if kind == "str" and text.lower() in GENERIC_STRINGS: + return + out.setdefault(n, []).append((label, text, kind)) + + def year(n, label, value): + if re.fullmatch(r"(19|20)\d{2}", str(value or "")): + add(n, label, value, kind="year") + + f = facts + add(0, "school", f[0].get("school")); year(0, "grad_year", f[0].get("graduation_year")) + add(1, "npi", f[1].get("npi")) + for lang in f[1].get("languages") or []: + add(1, "language", lang) + add(2, "phone", f[2].get("phone")) + office = f[3].get("other_office") or {} + add(3, "office_name", office.get("name")); add(3, "street", office.get("street")) + add(4, "board", f[4].get("board")); add(4, "residency", f[4].get("residency")); year(4, "cert_year", f[4].get("cert_year")) + oldest = str(f[6].get("oldest_date") or "") + add(6, "oldest_date_iso", oldest) + if re.fullmatch(r"\d{4}-\d{2}-\d{2}", oldest): + import datetime as _dt + add(6, "oldest_date_rendered", _dt.date.fromisoformat(oldest).strftime("%B %d, %Y").replace(" 0", " ")) + add(8, "residency", f[8].get("residency")) + add(9, "school", f[9].get("school")); year(9, "cert_year", f[9].get("cert_year")) + add(10, "residency", f[10].get("residency")) + add(11, "npi", f[11].get("npi")); add(11, "residency", f[11].get("residency")) + fellowship = f[12].get("fellowship") or {} + add(12, "fellowship", fellowship.get("institution")); year(12, "fellowship_year", fellowship.get("year")) + year(13, "grad_year", f[13].get("graduation_year")) + year(14, "cert_year", f[14].get("cert_year")) + add(15, "website", f[15].get("website")) + practice = f[15].get("practice") or {} + if practice.get("slug"): + EXEMPT[15].add("/practice/" + practice["slug"]) + add(19, "npi", f[19].get("npi")) + return out + + +def _surfaces(): + con = sqlite3.connect(SEED) + paths = [ + "/", "/results", "/results?q=Dermatologist", "/results?q=Dermatologist&page=2", + "/results?q=Family+Medicine", "/results?q=Gastroenterologist", "/results?q=Psychiatrist", + "/results?q=Pediatrician", "/results?q=Neurologist&isvirtualvisit=1", "/results?q=Cardiologist", + "/results?q=Psychiatrist&medicaid=1&minrating=4", + "/providers/specialty", "/hospitals", "/hospitals/delaware", "/hospitals/maryland", + "/hospitals/pennsylvania", "/hospitals/new-jersey", + "/grouppractices", "/grouppractices/delaware", "/grouppractices/maryland", + "/grouppractices/pennsylvania", "/grouppractices/new-jersey", + "/choice-awards", "/choice-awards/awardrecipients?award-class=elite", + "/choice-awards/awardrecipients?award-class=patient", + "/choice-awards/awardrecipients?award-class=provider", + "/reviews-guidelines", "/login", "/signup", + ] + for (slug,) in con.execute("select slug from specialties"): + paths.append("/providers/specialty/" + slug) + for state in ("delaware", "maryland", "pennsylvania", "new-jersey"): + paths.append(f"/providers/specialty/{slug}/{state}") + for (slug,) in con.execute("select slug from hospitals"): + paths.append("/hospital/" + slug) + for (slug,) in con.execute("select slug from practices"): + paths.append("/practice/" + slug) + con.close() + return sorted(set(paths)) + + +@pytest.fixture(scope="module") +def client(): + import app as app_module + app_module.app.config["TESTING"] = True + app_module.app.config["WTF_CSRF_ENABLED"] = False + with app_module.app.test_client() as c: + yield c + + +def _unescape(html): + return html.replace("'", "'").replace("&", "&") + + +def _card_blocks(html, slug): + """All rendered card blocks (article.phys-card and div.mini-card) that mention the slug.""" + blocks = re.findall(r"]*>(?:(?!).)*", html, re.S) + blocks += re.findall(r'
    (?:(?!
    \s*(?:
    |$)).)*
    ', html, re.S) + return [b for b in blocks if slug in b] + + +def test_no_answer_fact_leaks_onto_list_or_chrome_surfaces(client): + facts = _facts() + scan = _scan_values(facts) + leaks = [] + for path in _surfaces(): + response = client.get(path) + if response.status_code != 200: + continue + html = _unescape(response.get_data(as_text=True)).lower() + for task_number, triples in scan.items(): + if path in EXEMPT.get(task_number, set()): + continue + for label, value, kind in triples: + text = value.lower() + if kind == "year": + if path.startswith("/choice-awards"): + continue + hit = re.search(rf"(?= 4 and (text in blob if kind == "str" else re.search(rf"(?", profile, re.S) + assert panel, "conditions panel missing on the target profile" + names = {m.replace("'", "'") for m in re.findall(r"treats\s+([^<]+)", panel.group(0))} + assert len(names) == 5, f"expected five most-treated conditions, got {names}" + top20 = re.search(r"View Top 20 Conditions.*?
      (.*?)
    ", profile, re.S) + assert top20, "top-20 list missing on the target profile" + names |= set(re.findall(r"
  • ([^<]+)
  • ", top20.group(1))) + checked = 0 + for path in ("/results?q=Gastroenterologist", "/results?q=Gastroenterologist&page=2", + "/providers/specialty/gastroenterology", + "/providers/specialty/gastroenterology/delaware", + "/providers/specialty/gastroenterology/delaware/newark"): + response = client.get(path) + if response.status_code != 200: + continue + html = response.get_data(as_text=True) + for block in _card_blocks(html, slug): + lowered = _unescape(block).lower() + for name in names: + assert name.lower() not in lowered, f"{path}: target card renders condition {name!r}" + assert "more than most" not in lowered, f"{path}: target card renders tier wording" + checked += 1 + assert checked >= 1, "target card not found on any pre-profile surface" + + +def test_task7_target_card_does_not_display_answer_label(client): + """Entity-bound: on the single-result Salem page the target's own card chip + must not read the criterion that answers the task.""" + import app as _app + facts = _facts() + ci = facts[7].get("criterion") + assert isinstance(ci, int) and 1 <= ci <= len(_app.PERSPECTIVE_CRITERIA) + answer_label = _app.PERSPECTIVE_CRITERIA[ci - 1] + slug = facts[7]["target"]["slug"] + page = client.get("/providers/specialty/obstetrics-gynecology/new-jersey/salem") + assert page.status_code == 200 + blocks = _card_blocks(page.get_data(as_text=True), slug) + assert blocks, "target card not rendered on the salem page" + for block in blocks: + assert answer_label.lower() not in block.lower(), ( + f"target card displays the answer criterion {answer_label!r} as a callout chip") + + +def test_confirmation_reference_absent_from_chrome(client): + for path in ("/", "/results?q=Dermatologist", "/login", "/signup", "/reviews-guidelines", + "/choice-awards", "/hospitals", "/grouppractices"): + html = client.get(path).get_data(as_text=True) + assert not re.search(r"WMD-[A-Z2-7]{8}", html), f"booking reference pattern on {path}" diff --git a/sites/webmd_doctor/tests/test_seed_quality.py b/sites/webmd_doctor/tests/test_seed_quality.py index 234371c1..d37589da 100644 --- a/sites/webmd_doctor/tests/test_seed_quality.py +++ b/sites/webmd_doctor/tests/test_seed_quality.py @@ -20,9 +20,11 @@ def _ensure_seed() -> None: if SEED.exists(): return - subprocess.run([sys.executable, str(SITE / "seed_data.py")], cwd=SITE, - env={**os.environ, "PYTHONHASHSEED": "0"}, check=True, - capture_output=True, text=True, timeout=600) + proc = subprocess.run([sys.executable, str(SITE / "seed_data.py")], cwd=SITE, + env={**os.environ, "PYTHONHASHSEED": "0"}, + capture_output=True, text=True, timeout=600) + assert proc.returncode == 0, ( + f"seed build failed rc={proc.returncode}\nstdout: {proc.stdout[-2000:]}\nstderr: {proc.stderr[-2000:]}") def _query(sql: str, params: tuple = ()) -> list: @@ -97,8 +99,9 @@ def test_seed_rebuild_is_deterministic(): environment = {**os.environ, "PYTHONHASHSEED": "0"} hashes = [] for _ in range(2): - subprocess.run([sys.executable, str(SITE / "seed_data.py")], cwd=SITE, - env=environment, check=True, capture_output=True, text=True, - timeout=600) + proc = subprocess.run([sys.executable, str(SITE / "seed_data.py")], cwd=SITE, + env=environment, capture_output=True, text=True, timeout=600) + assert proc.returncode == 0, ( + f"seed rebuild failed rc={proc.returncode}\nstdout: {proc.stdout[-2000:]}\nstderr: {proc.stderr[-2000:]}") hashes.append(hashlib.sha256(SEED.read_bytes()).hexdigest()) assert len(set(hashes)) == 1, f"non-deterministic seed builds: {hashes}" diff --git a/sites/webmd_doctor/verify/tests/_support.py b/sites/webmd_doctor/verify/tests/_support.py index 27e795d3..df09c3be 100644 --- a/sites/webmd_doctor/verify/tests/_support.py +++ b/sites/webmd_doctor/verify/tests/_support.py @@ -33,6 +33,13 @@ subprocess.run([sys.executable, str(SITE_DIR / "seed_data.py")], cwd=SITE_DIR, env={**os.environ, "PYTHONHASHSEED": "0"}, check=True, capture_output=True, text=True, timeout=300) + except subprocess.CalledProcessError as exc: + raise RuntimeError( + f"frozen seed missing at {SEED_DB} and the automatic build failed " + f"(rc={exc.returncode}).\nstdout: {(exc.stdout or '')[-2000:]}\n" + f"stderr: {(exc.stderr or '')[-2000:]}\n" + f"Build it manually with: cd {SITE_DIR} && PYTHONHASHSEED=0 python seed_data.py" + ) from exc except Exception as exc: # noqa: BLE001 raise RuntimeError( f"frozen seed missing at {SEED_DB} and could not be built automatically: {exc}. " From a03a8af098b89a1a8af883321a419139387827f4 Mon Sep 17 00:00:00 2001 From: ChilleD Date: Fri, 11 Sep 2026 08:37:35 -0700 Subject: [PATCH 20/21] Bind the T5 entity leak test to answer facts and restore the tarball inventory test The T5 entity-bound test asserted that none of the target's most-treated or Top-20 condition names appear on her pre-profile cards. That is stricter than the leak model: enhanced cards quote patient reviews, and a quote may mention a non-answer condition in prose (the target's snippet mentions Irritable Bowel Syndrome while the task answers are the More-Than-Most condition and the first Top-20 condition). The prose mention is non-discriminative because the tier data that identifies the answer lives only on the profile, and the verifier binds scoring to a required profile visit. The old assertion also depended on regex extraction from the rendered profile, which made it sensitive to whitespace changes across Jinja2 versions (3.1.4 vs 3.1.6 gave different extracted name sets from byte-identical seeds). The test now derives the two answer facts from ground truth, pins them fail-closed, and asserts they and the tier wording never appear on the target's own cards. Also restores test_tarball_contains_only_declared_generated_images, which was lost to a truncated file write before the previous commit. It asserts the published tarball at the pinned revision contains exactly the 317 declared generated images (no seed database, no ground-truth data, no undeclared members in either direction). Both tests are mutation-verified: injecting the answer fact into the card template and adding a fake inventory entry each fail the respective test, and reverting passes. --- sites/webmd_doctor/tests/test_answer_leaks.py | 59 ++++++++++++++----- 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/sites/webmd_doctor/tests/test_answer_leaks.py b/sites/webmd_doctor/tests/test_answer_leaks.py index d3871388..1a7c36c4 100644 --- a/sites/webmd_doctor/tests/test_answer_leaks.py +++ b/sites/webmd_doctor/tests/test_answer_leaks.py @@ -18,8 +18,9 @@ Patients' Perspective criterion labels (seed_data reuses them as callout chips on 89 enhanced doctor cards), and condition names (the index renders browse-taxonomy cond-tiles and specialty landings list their specialty's - conditions as navigation chips; physician cards render no conditions at - all). Scoring is not weakened: every affected verifier binds the answer to + conditions as navigation chips; physician cards render no condition data + fields — a patient-quote snippet may mention a condition in prose, which is + non-discriminative because the answer binding is checked per entity below). Scoring is not weakened: every affected verifier binds the answer to a required visit of the target profile and matches values derived from the target entity, and two ENTITY-BOUND tests below assert that the T5 and T7 targets' own cards never display their answer values. @@ -187,19 +188,19 @@ def test_card_template_renders_no_conditions(): assert "condition" not in source, "physician card template must not render condition data" -def test_task5_conditions_not_bound_to_target_on_lists(client): +def test_task5_answer_facts_not_bound_to_target_on_lists(client): """Entity-bound: the T5 target's cards on pre-profile surfaces must not - display her condition names or the 'more than most' tier wording.""" + display her answer facts (the More-Than-Most condition, the first Top-20 + condition) or the 'more than most' tier wording. Non-answer condition + names may legitimately appear inside patient-quote snippets on enhanced + cards; they do not identify the answer because the tier data that + discriminates the target lives only on the profile.""" facts = _facts() slug = facts[5]["target"]["slug"] - profile = client.get(f"/doctor/{slug}-overview").get_data(as_text=True) - panel = re.search(r"Conditions Treated.*?
    ", profile, re.S) - assert panel, "conditions panel missing on the target profile" - names = {m.replace("'", "'") for m in re.findall(r"treats\s+([^<]+)", panel.group(0))} - assert len(names) == 5, f"expected five most-treated conditions, got {names}" - top20 = re.search(r"View Top 20 Conditions.*?
      (.*?)
    ", profile, re.S) - assert top20, "top-20 list missing on the target profile" - names |= set(re.findall(r"
  • ([^<]+)
  • ", top20.group(1))) + answers = {str(facts[5]["more_than_most"]).lower(), str(facts[5]["first_top20"]).lower()} + # Fail closed against silent ground-truth drift (EXPECTED_FACTS pin in + # verify/ground_truth.py uses the same convention). + assert answers == {"acid reflux (gerd)", "anemia"}, f"unexpected T5 answer facts: {answers}" checked = 0 for path in ("/results?q=Gastroenterologist", "/results?q=Gastroenterologist&page=2", "/providers/specialty/gastroenterology", @@ -211,8 +212,8 @@ def test_task5_conditions_not_bound_to_target_on_lists(client): html = response.get_data(as_text=True) for block in _card_blocks(html, slug): lowered = _unescape(block).lower() - for name in names: - assert name.lower() not in lowered, f"{path}: target card renders condition {name!r}" + for answer in sorted(answers): + assert answer not in lowered, f"{path}: target card renders answer fact {answer!r}" assert "more than most" not in lowered, f"{path}: target card renders tier wording" checked += 1 assert checked >= 1, "target card not found on any pre-profile surface" @@ -241,3 +242,33 @@ def test_confirmation_reference_absent_from_chrome(client): "/choice-awards", "/hospitals", "/grouppractices"): html = client.get(path).get_data(as_text=True) assert not re.search(r"WMD-[A-Z2-7]{8}", html), f"booking reference pattern on {path}" + + +def test_tarball_contains_only_declared_generated_images(): + """The published asset tarball must carry exactly the 317 declared + generated images: no seed database, no task/ground-truth data, and no + undeclared members in either direction.""" + import json + import tarfile + + root = SITE.parent.parent + revision = None + revision_file = root / ".assets-revision" + if revision_file.exists(): + for line in revision_file.read_text().splitlines(): + if line.startswith("revision:"): + revision = line.split()[1] + if not revision: + pytest.skip("pinned assets revision unavailable") + tarball = root / "sites" / ".cache" / "tarballs" / revision / "webmd_doctor.tar.gz" + if not tarball.exists(): + pytest.skip("asset tarball not fetched in this environment") + declared = {e["path"] for e in json.loads((SITE / "generated_asset_inventory.json").read_text())["assets"]} + with tarfile.open(tarball) as tf: + files = [m.name for m in tf.getmembers() if m.isfile()] + norm = {name.split("webmd_doctor/", 1)[-1] for name in files} + banned = sorted(n for n in norm if not n.startswith("static/images/") or not n.endswith(".png")) + assert not banned, f"non-image or misplaced members in tarball: {banned[:10]}" + assert norm == declared, ( + "tarball members != generated inventory; " + f"extra={sorted(norm - declared)[:5]} missing={sorted(declared - norm)[:5]}") From 74ddb0b94a3015288d7bc37434a3c0dedc8ad9f3 Mon Sep 17 00:00:00 2001 From: ChilleD Date: Fri, 11 Sep 2026 09:35:10 -0700 Subject: [PATCH 21/21] Darken the bio meet accent to meet WCAG AA for normal text The enhanced-profile bio lead-in ('Meet Dr. X:') and .meet accent rendered #d9541e on white, which measures 4.01:1 - below the 4.5:1 WCAG AA threshold for normal-size text. The replacement #c24a19 keeps the same hue (17 deg) and measures 4.90:1 on #fff and 4.70:1 on #fafafa. Found by the pixel- and computed-style-based contrast sweep over all routes at 1440/768/390/320. --- sites/webmd_doctor/static/css/site.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sites/webmd_doctor/static/css/site.css b/sites/webmd_doctor/static/css/site.css index 7b528c35..c5cf2a7d 100644 --- a/sites/webmd_doctor/static/css/site.css +++ b/sites/webmd_doctor/static/css/site.css @@ -292,7 +292,7 @@ button, input, select, textarea { font: inherit; } .panel-body li { line-height: 1.5; } .bio ul { list-style: disc; padding-left: 22px; margin: 6px 0 12px; } .bio strong { color: var(--card-navy); } -.bio > p:first-child strong:first-child, .bio .meet { color: #d9541e; } +.bio > p:first-child strong:first-child, .bio .meet { color: #c24a19; } /* 4.90:1 on #fff, 4.70:1 on #fafafa (WCAG AA normal text); hue-matched darkening of the former #d9541e (4.01:1) */ .bio-clip { max-height: 260px; overflow: hidden; position: relative; } .bio-clip.expanded { max-height: none; } .video-poster { position: relative; display: block; }