diff --git a/.assets-revision b/.assets-revision index 2668ebe1..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: 68dcbf2cbd0cbc2fbf97dcb71da13ace67c11bf6 +revision: ad6f424f72cada9e6f5c09a58093d0ceeab9c52b diff --git a/.claude/skills/clone-website/SKILL.md b/.claude/skills/clone-website/SKILL.md index dddbc216..c1fb0f7d 100644 --- a/.claude/skills/clone-website/SKILL.md +++ b/.claude/skills/clone-website/SKILL.md @@ -204,7 +204,7 @@ Then run the site once locally to produce `instance/.db`, copy it to ```bash ./scripts/build.sh webharbor:dev docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41024:40000-40024 webharbor:dev + -p 8201:8101 -p 41000-41025:40000-40025 webharbor:dev # your new site is on port 41000 + its index curl -so /dev/null -w "%{http_code}\n" http://localhost:41000NN/ @@ -228,7 +228,7 @@ After Phase 1, you should have: - `sites//static/` with real CSS/JS/icons (and images under HF assets) - `sites//instance_seed/.db` with seeded data - Site registered in `websyn_start.sh`, `control_server.py`, `Dockerfile` -- All 25 sites still return 200 on the alt-port container +- All 26 sites still return 200 on the alt-port container - Byte-identical reset passes ## Next step diff --git a/.claude/skills/evolve-env/SKILL.md b/.claude/skills/evolve-env/SKILL.md index de1f1e28..5d948d83 100644 --- a/.claude/skills/evolve-env/SKILL.md +++ b/.claude/skills/evolve-env/SKILL.md @@ -65,7 +65,7 @@ through `BASE_DIR = os.path.dirname(os.path.abspath(__file__))`. After any DB-affecting change: ```bash -# 1. Stop test container (don't touch user's working container on :40000-40024) +# 1. Stop test container (don't touch user's working container on :40000-40025) docker stop wh-test 2>/dev/null || true # 2. Rebuild @@ -73,7 +73,7 @@ docker stop wh-test 2>/dev/null || true # 3. Run on alt ports docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41024:40000-40024 webharbor:dev + -p 8201:8101 -p 41000-41025:40000-40025 webharbor:dev # 4. Reset your site and confirm byte-identity curl -X POST http://localhost:8201/reset/ @@ -149,7 +149,7 @@ After Phase 3: - Every task in `tasks.jsonl` is hand-verified to work end-to-end - The mirror has no obvious leaks, broken forms, or empty pages - Byte-identical reset passes -- All 25 sites still return 200 +- All 26 sites still return 200 ## Next step diff --git a/.claude/skills/harden-env/SKILL.md b/.claude/skills/harden-env/SKILL.md index f456829f..536be14a 100644 --- a/.claude/skills/harden-env/SKILL.md +++ b/.claude/skills/harden-env/SKILL.md @@ -130,7 +130,7 @@ Hardening changes seed data. After any DB change: ```bash ./scripts/build.sh webharbor:dev docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41024:40000-40024 webharbor:dev + -p 8201:8101 -p 41000-41025:40000-40025 webharbor:dev curl -X POST http://localhost:8201/reset/ docker exec wh-test md5sum \ /opt/WebSyn//instance/.db \ diff --git a/.claude/skills/review-env/SKILL.md b/.claude/skills/review-env/SKILL.md index c14d1d66..18b135f9 100644 --- a/.claude/skills/review-env/SKILL.md +++ b/.claude/skills/review-env/SKILL.md @@ -33,18 +33,18 @@ gh pr checkout ./scripts/fetch_assets.sh # pull the pinned HF revision ./scripts/build.sh webharbor:dev docker run -d --rm --name wh-review \ - -p 8201:8101 -p 41000-41024:40000-40024 webharbor:dev + -p 8201:8101 -p 41000-41025:40000-40025 webharbor:dev ``` -Confirm the new/changed site is on the expected port (40000 + index). Note: the image now runs 25 sites (40000-40024). +Confirm the new/changed site is on the expected port (40000 + index). Note: the image now runs 26 sites (40000-40025). ### Step 2: The mechanical checks (5 minutes) Run the same Pre-PR checks the contributor was supposed to run. ```bash -# 1. all 25 sites return 200 -for p in $(seq 41000 41024); do +# 1. all 26 sites return 200 +for p in $(seq 41000 41025); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done @@ -231,7 +231,7 @@ Leave a structured comment on the PR: ## Review: ### Mechanical checks: PASS / FAIL -- [x] All 25 sites return 200 +- [x] All 26 sites return 200 - [x] Control plane healthy - [x] Byte-identical reset (md5 match) - [x] Parallel reset <10s diff --git a/.claude/skills/seed-database/SKILL.md b/.claude/skills/seed-database/SKILL.md index 25a5649d..ec69d5f7 100644 --- a/.claude/skills/seed-database/SKILL.md +++ b/.claude/skills/seed-database/SKILL.md @@ -169,10 +169,10 @@ gh pr create ./scripts/check_assets.sh # every site has instance_seed/ ./scripts/build.sh webharbor:dev # docker build succeeds docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41024:40000-40024 webharbor:dev + -p 8201:8101 -p 41000-41025:40000-40025 webharbor:dev -# all 25 sites return 200 -for p in $(seq 41000 41024); do +# all 26 sites return 200 +for p in $(seq 41000 41025); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done diff --git a/AGENTS.md b/AGENTS.md index 3c84c3b2..d2feea97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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-40024:40000-40024 webharbor:dev +docker run -d -p 8101:8101 -p 40000-40025:40000-40025 webharbor:dev ``` Or use the published image directly: ```bash -docker run -d -p 8101:8101 -p 40000-40024:40000-40024 \ +docker run -d -p 8101:8101 -p 40000-40025:40000-40025 \ battalion7244/webharbor:latest ``` -Sites are on `40000`-`40024` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: +Sites are on `40000`-`40025` 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-41024:40000-40024 webharbor:dev + -p 8201:8101 -p 41000-41025:40000-40025 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 41024); do +for p in $(seq 41000 41025); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done diff --git a/CLAUDE.md b/CLAUDE.md index ad0e2fd0..ae29df2f 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-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`). +If a container is already running on `:8101` / `:40000-40025`, 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-41025`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1029fa9b..5a452f85 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-40024:40000-40024 webharbor:dev + -p 8101:8101 -p 40000-40025:40000-40025 webharbor:dev # iterate locally... ./scripts/extract_assets.sh ../webharbor-static-pr/ # split assets out diff --git a/Dockerfile b/Dockerfile index 1133ef34..68194582 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # WebHarbor — slim, self-contained image. -# 25 Flask mirror sites + control plane on :8101. +# 26 Flask mirror sites + control plane on :8101. FROM python:3.12-slim-bookworm @@ -56,6 +56,14 @@ RUN python3 /opt/check_asset_inventory.py /opt/WebSyn/fedex RUN cd /opt/WebSyn/fedex && rm -rf instance instance_seed && \ PYTHONHASHSEED=0 python seed_data.py && rm -rf instance +# WebMD Doctor's generated avatars / posters come from the pinned asset bundle, +# while its SQLite seed is rebuilt deterministically from tracked source code. +# 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__ + COPY websyn_start.sh /opt/websyn_start.sh COPY control_server.py /opt/control_server.py COPY site_runner.py /opt/site_runner.py @@ -78,6 +86,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-40024 +EXPOSE 8101 40000-40025 CMD ["/opt/websyn_start.sh"] diff --git a/README.md b/README.md index 08632849..7830b870 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** — 25 sites today, scaling to 100+ together +- **Community-driven** — 26 sites today, scaling to 100+ together ## 🚀 Quickstart One command to run all web environments: ```bash -docker run -p 8101:8101 -p 40000-40024:40000-40024 battalion7244/webharbor:latest +docker run -p 8101:8101 -p 40000-40025:40000-40025 battalion7244/webharbor:latest ``` -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 FedEx`. +Then point your agent at `http://localhost:40000` through `http://localhost:40025` to explore 26 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, FedEx, 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 c3d75c28..1d6ec7ad 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-40024:40000-40024 battalion7244/webharbor:latest`). +WebHarbor must already be running locally (`docker run -p 8101:8101 -p 40000-40025:40000-40025 battalion7244/webharbor:latest`). Run a single task from a site's `tasks.jsonl`: diff --git a/control_server.py b/control_server.py index 6347c5de..fb3fa968 100644 --- a/control_server.py +++ b/control_server.py @@ -28,6 +28,7 @@ 'huggingface', 'wolfram_alpha', 'cambridge_dictionary', 'coursera', 'espn', 'merriam_webster', 'ikea', 'phys_org', 'target', 'ted', 'osu', 'rotten_tomatoes', 'compass', 'walmart_careers', 'fedex', + 'webmd_doctor', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' 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/sites/fedex/README.md b/sites/fedex/README.md index 17ba3d6f..b0397d01 100644 --- a/sites/fedex/README.md +++ b/sites/fedex/README.md @@ -1,6 +1,6 @@ # FedEx -An offline FedEx mirror contributed in [WebHarbor #50](https://github.com/aiming-lab/WebHarbor/pull/50) and revised in [WebHarbor #82](https://github.com/aiming-lab/WebHarbor/pull/82), with package tracking, rate estimates, a shipping flow, pickup scheduling, a location directory, local support articles, accounts, shipment history, invoices and claims. FedEx uses port **40024** in the current 25-site registry. +An offline FedEx mirror contributed in [WebHarbor #50](https://github.com/aiming-lab/WebHarbor/pull/50) and revised in [WebHarbor #82](https://github.com/aiming-lab/WebHarbor/pull/82), with package tracking, rate estimates, a shipping flow, pickup scheduling, a location directory, local support articles, accounts, shipment history, invoices and claims. FedEx uses port **40024** in the current 26-site registry. ## Data and assets diff --git a/sites/rotten_tomatoes/tests/test_environment_quality.py b/sites/rotten_tomatoes/tests/test_environment_quality.py index f152c2c6..e3c9f7c0 100644 --- a/sites/rotten_tomatoes/tests/test_environment_quality.py +++ b/sites/rotten_tomatoes/tests/test_environment_quality.py @@ -94,6 +94,10 @@ def test_task_manifest_and_registry(self): sites = re.search(r'SITES=\((.*?)\)', startup, re.S).group(1).split() self.assertIn('rotten_tomatoes', sites) self.assertEqual(40000 + sites.index('rotten_tomatoes'), 40021) + self.assertIn('fedex', sites) + self.assertEqual(40000 + sites.index('fedex'), 40024) + self.assertIn('webmd_doctor', sites) + self.assertEqual(40000 + sites.index('webmd_doctor'), 40025) self.assertIn("'ted', 'osu', 'rotten_tomatoes'", (ROOT / 'control_server.py').read_text()) self.assertIn(f'40000-{40000 + len(sites) - 1}', (ROOT / 'Dockerfile').read_text()) self.assertTrue((SITE / '.build-generated-seed').is_file()) diff --git a/sites/walmart_careers/README.md b/sites/walmart_careers/README.md index 4241bc6a..c9616882 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 26-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 1582a4a8..c7b4c8eb 100644 --- a/sites/walmart_careers/tests/test_integration.py +++ b/sites/walmart_careers/tests/test_integration.py @@ -16,7 +16,7 @@ "cambridge_dictionary", "coursera", "espn", "merriam_webster", "ikea", "phys_org", "target", "ted", "osu", "rotten_tomatoes", "compass", ] -FIXED_PORTS = {"rotten_tomatoes": 40021, "compass": 40022, "walmart_careers": 40023, "fedex": 40024} +FIXED_PORTS = {"rotten_tomatoes": 40021, "compass": 40022, "walmart_careers": 40023, "fedex": 40024, "webmd_doctor": 40025} def registered_sites() -> list[str]: @@ -54,7 +54,7 @@ def control_sites(): def test_registry_is_consistent_and_preserves_assigned_ports(): sites = registered_sites() assert sites[:len(PREFIX_SITES)] == PREFIX_SITES - assert "walmart_careers" in sites and "fedex" in sites + assert "walmart_careers" in sites and "fedex" in sites and "webmd_doctor" in sites assert len(sites) == len(set(sites)), "duplicate site in the registry" for site, port in FIXED_PORTS.items(): assert site_port(site) == port, f"{site} moved to {site_port(site)}, expected {port}" @@ -68,6 +68,9 @@ def test_docker_preserves_build_gates_for_every_inventoried_site(): for site in ("compass", "walmart_careers", "fedex"): assert f"check_asset_inventory.py /opt/WebSyn/{site}" in text, site assert f"cd /opt/WebSyn/{site}" in text, site + # WebMD Doctor ships its own generated-asset gate plus a source-built seed. + assert "/opt/WebSyn/webmd_doctor/check_generated_assets.py" in text + assert "cd /opt/WebSyn/webmd_doctor" in text assert "walmart_careers/check_tracked_assets.py" in text for site in ("osu", "rotten_tomatoes"): assert f"cd /opt/WebSyn/{site}" in text, site @@ -85,7 +88,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 == "68dcbf2cbd0cbc2fbf97dcb71da13ace67c11bf6" + assert revision == "ad6f424f72cada9e6f5c09a58093d0ceeab9c52b" assert (SITE / ".build-generated-seed").is_file() assert (SITE / ".requires-images").is_file() assert (SITE / "asset_inventory.json").is_file() @@ -94,7 +97,7 @@ def test_assets_pin_is_immutable_merged_revision(): def test_shared_documentation_uses_the_current_site_range(): current = port_range() - stale = {f"40000-400{end}" for end in range(20, 24)} - {current} + stale = {f"40000-400{end}" for end in range(20, 25)} - {current} for relative in ["README.md", "AGENTS.md", "CONTRIBUTING.md", "CLAUDE.md", "agent_demo/README.md"]: text = (ROOT / relative).read_text() for old in stale: diff --git a/sites/webmd_doctor/.build-generated-seed b/sites/webmd_doctor/.build-generated-seed new file mode 100644 index 00000000..7f8edd50 --- /dev/null +++ b/sites/webmd_doctor/.build-generated-seed @@ -0,0 +1 @@ +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/.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/.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 new file mode 100644 index 00000000..7bdc8f4e --- /dev/null +++ b/sites/webmd_doctor/README.md @@ -0,0 +1,49 @@ +# WebMD Doctor mirror + +Offline Flask mirror of `https://doctor.webmd.com/` (branded "WebMD Care" upstream). In the 26-site registry it is site index 25 and runs on container port `40025`. 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 +./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=40025 ../../.venv/bin/python app.py +``` + +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 + +| 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 | 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 | 1227 | doctor_perspectives | 1582 | +| certifications / licenses / education | 307 / 295 / 595 | awards / doctor_languages | 50 / 363 | +| 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!` (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` (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 `