From 394d5dae3ff70fd04de3acc70fe12ac8e3f8e484 Mon Sep 17 00:00:00 2001 From: Leonard Date: Sat, 15 Aug 2026 16:52:23 +0000 Subject: [PATCH] =?UTF-8?q?feat(auditing):=20pipeline=20v1.1=20=E2=80=94?= =?UTF-8?q?=20report=20generation=20+=20scripted=20gate=20+=20temporal=20d?= =?UTF-8?q?elta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - report_generate.py: REPORT-final.md + VALIDATION.md from findings + raw capture; per-surface status from summary.surfaces_ok; Data Limitations - report_gate.py: §6 gate scripted (count/evidence/identity/material support); non-zero exit blocks delivery - audit_diff.py: before/after capture comparison -> DELTA.md - audit_pipeline.sh: 2 steps -> 4; collision-safe capture copy (re-runs preserve baseline); relative output dir resolved to absolute - beta-audit-process.md: locked v1.0 -> v1.1 (+ dated decision record) - all 9 runs of the 2026-08-15 batch regenerated + gated (9/9 PASS) - live end-to-end proof: Gilmore re-run, DELTA.md 0 changes (same day) --- .gitignore | 3 + .../2026-08-15-audit-pipeline-v1-1.md | 62 +++ docs/operations/beta-audit-process.md | 27 +- .../REPORT-final.md | 75 ++++ .../VALIDATION.md | 66 +-- .../REPORT-final.md | 144 ++++++ .../VALIDATION.md | 81 ++-- .../REPORT-final.md | 17 +- .../VALIDATION.md | 18 +- .../2026-08-15-air-tech-pros/REPORT-final.md | 13 +- .../2026-08-15-air-tech-pros/VALIDATION.md | 18 +- .../REPORT-final.md | 13 +- .../VALIDATION.md | 18 +- .../REPORT-final.md | 12 +- .../VALIDATION.md | 18 +- .../REPORT-final.md | 13 +- .../VALIDATION.md | 18 +- .../2026-08-15-gilmore-heating-air/DELTA.md | 24 + .../REPORT-final.md | 127 ++++-- .../VALIDATION.md | 82 ++-- .../findings.json | 11 +- .../findings.md | 10 +- ...i_surface_2026-08-15__20260815-164620.json | 170 +++++++ .../REPORT-final.md | 13 +- .../VALIDATION.md | 18 +- .../REPORT-final.md | 21 +- .../2026-08-15-gordon-law-firm/VALIDATION.md | 24 +- .../REPORT-final.md | 13 +- .../VALIDATION.md | 18 +- implementation/auditing/audit_diff.py | 100 +++++ implementation/auditing/audit_pipeline.sh | 29 +- implementation/auditing/report_gate.py | 208 +++++++++ implementation/auditing/report_generate.py | 425 ++++++++++++++++++ 33 files changed, 1681 insertions(+), 228 deletions(-) create mode 100644 .gitignore create mode 100644 docs/decisions/2026-08-15-audit-pipeline-v1-1.md create mode 100644 docs/validation/2026-08-14-crystal-blue-plumbing/REPORT-final.md create mode 100644 docs/validation/2026-08-14-superior-hvac-plumbing/REPORT-final.md create mode 100644 docs/validation/2026-08-15-gilmore-heating-air/DELTA.md create mode 100644 docs/validation/2026-08-15-gilmore-heating-air/gilmore_heating_air_and_plumbing_multi_surface_2026-08-15__20260815-164620.json create mode 100644 implementation/auditing/audit_diff.py create mode 100644 implementation/auditing/report_gate.py create mode 100644 implementation/auditing/report_generate.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ea1db1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +logs/ diff --git a/docs/decisions/2026-08-15-audit-pipeline-v1-1.md b/docs/decisions/2026-08-15-audit-pipeline-v1-1.md new file mode 100644 index 0000000..a7ce409 --- /dev/null +++ b/docs/decisions/2026-08-15-audit-pipeline-v1-1.md @@ -0,0 +1,62 @@ +# Decision: Audit pipeline promoted to v1.1 + +**Date:** 2026-08-15 +**Status:** Approved +**Supersedes:** Locked v1.0 process (2026-08-15) — same date, same day, tighter mechanics + +## Change + +`beta-audit-process.md` moves from **Locked v1.0** to **Locked v1.1**. +The locked process said *what* must be true (report, gate, artifact set). +v1.1 wires the *how* into the pipeline so the guarantees hold without +human ritual. + +## What changed in the toolchain + +`implementation/auditing/` gains two scripts and the pipeline grows from +2 steps to 4: + +```text +multi_scraper → audit_engine → report_generate → report_gate +``` + +1. **`report_generate.py`** — builds `REPORT-final.md` + `VALIDATION.md` + from the run's `findings.json` + raw capture. Per-surface status in the + Surfaces Reviewed table is derived from `summary.surfaces_ok` + (✓/✗/⚠ with reason); a Data Limitations section states the known + ceilings (no review text, no authenticated hours, point-in-time). +2. **`report_gate.py`** — the §6 pre-delivery gate, scripted. Four checks: + count match, evidence present (absence-type findings re-derived from + raw capture instead of requiring quoted evidence), identity match, and + material support (every material finding re-derived against the raw + capture). Non-zero exit on failure; result stamped into `VALIDATION.md`. +3. **`audit_diff.py`** — temporal delta between two raw captures → + `DELTA.md` in the after-run folder. The proof-of-fix artifact: after the + client applies recommendations, re-run and the delta is the invoice + evidence. +4. **`audit_pipeline.sh`** — steps 3–4 wired in; gate FAIL blocks delivery + (`exit 1`). Capture copy is collision-safe: a re-run never overwrites + the prior capture (timestamped sibling file), which is what makes the + temporal baseline in `audit_diff.py` possible. + +## Why + +- The v1.0 gate was manual; a 9-run batch showed the manual gate drifts + (reports were regenerated by a tmp script outside the repo). +- Re-runs overwrote prior captures, so before/after proof was impossible. +- The report builder lived in `/tmp`, not the repo, so "report is a + pipeline output" was a convention, not a mechanism. + +## Verification (2026-08-15) + +- All 9 runs of the 2026-08-15 batch regenerated through + `report_generate.py`; `report_gate.py` PASS 9/9. +- Live end-to-end proof: Gilmore Heating, Air and Plumbing re-run through + the full 4-step pipeline. Baseline capture preserved as a timestamped + sibling; report regenerated; gate PASS; `audit_diff.py` produced + `DELTA.md` (0 field changes — same day, as expected). + +## No change to + +- Scope (four surfaces), intake, reviewer disposition loop, blocked-surface + treatment (§4), delivery. v1.1 changes mechanics, not meaning. diff --git a/docs/operations/beta-audit-process.md b/docs/operations/beta-audit-process.md index 9e22f53..d903dba 100644 --- a/docs/operations/beta-audit-process.md +++ b/docs/operations/beta-audit-process.md @@ -1,6 +1,6 @@ # VeriPath Beta Audit Process -**Status:** Locked v1.0 — 2026-08-15, for beta use +**Status:** Locked v1.1 — 2026-08-15, for beta use (v1.1 decision: `docs/decisions/2026-08-15-audit-pipeline-v1-1.md`) **Supersedes:** 2026-08-14 draft lock ## Purpose @@ -39,9 +39,13 @@ Collect: Run: ```text -multi_scraper → audit_engine +multi_scraper → audit_engine → report_generate → report_gate ``` +The pipeline writes the full required artifact set itself. `report_gate` +fails the run (non-zero exit) if the §6 gate fails — a failing run does +not deliver. + ### 3. Human validation (reviewer decision loop) The reviewer must resolve every engine finding to exactly one disposition. @@ -74,14 +78,21 @@ language or marketing urgency. ### 6. Pre-delivery consistency gate -All three checks pass before the report is sent. Any failure blocks delivery: +Executed by `implementation/auditing/report_gate.py` as pipeline step 4; +result stamped into `VALIDATION.md`. All four checks pass before the +report is sent. Any failure blocks delivery: 1. **Count match:** the executive summary's stated number of findings equals the number of findings in the Findings section. 2. **Evidence present:** every finding's cited evidence exists in the run's - archived artifacts (raw capture or `findings.json`). + archived artifacts (raw capture or `findings.json`). Absence-type findings + (no website, no description, UTM parameters, unreachable surface) are + re-derived from the raw capture instead of requiring quoted evidence. 3. **Identity match:** report client name, location, and audit date match the intake record. +4. **Material support:** each material finding is re-derived against the raw + capture (phone cores across surfaces, hours day counts, rating, website + presence, description/OG fields). ### 7. Delivery @@ -99,6 +110,11 @@ Every run is archived under `docs/validation/-/` with: | `findings.md` | engine markdown | | `REPORT-final.md` | client-facing report (post-review) | | `VALIDATION.md` | run notes, surface results, reviewer decisions | +| `DELTA.md` | before/after field comparison (re-runs only) | + +Re-runs never overwrite the prior raw capture: the pipeline writes a +timestamped sibling (`*__YYYYMMDD-HHMMSS.json`). The prior capture is the +temporal baseline; `audit_diff.py` compares the two into `DELTA.md`. Runs completed before 2026-08-15 are grandfathered as-is. Every run on or after this date must complete the set before delivery. @@ -129,7 +145,8 @@ implementation/auditing/ The standard execution path is: ```text -multi_scraper → audit_engine +multi_scraper → audit_engine → report_generate → report_gate +audit_diff (re-runs: baseline capture vs. new capture) ``` ## Deviations diff --git a/docs/validation/2026-08-14-crystal-blue-plumbing/REPORT-final.md b/docs/validation/2026-08-14-crystal-blue-plumbing/REPORT-final.md new file mode 100644 index 0000000..6685a3e --- /dev/null +++ b/docs/validation/2026-08-14-crystal-blue-plumbing/REPORT-final.md @@ -0,0 +1,75 @@ +# Crystal Blue Plumbing Heating & Air +## Online Presence Audit + +**Location:** , CA +**Vertical:** Local Services +**Audit date:** 14 August 2026 +**Prepared for:** Crystal Blue Plumbing Heating & Air +**Prepared by:** VeriPath + +--- + +## Executive Summary + +We audited Crystal Blue Plumbing Heating & Air across its primary online surfaces: Apple Maps. The business shows 4.2 ★ across 65 Google reviews. +**2 material integrity issues were identified:** + +1. No website could be located for this business. +2. No business description is set on any directory. + +--- + +## Surfaces Reviewed + +| Surface | Status | Rating | Reviews | +|---------|--------|--------|---------| +| Google Business Profile | ✗ | capture failed — excluded from this audit | — | +| Apple Maps | ✓ | 4.2 ★ | 65 | +| Bing Places | ✗ | capture blocked (bot-wall) — excluded, not verified | — | +| Website | ✗ | not found / unreachable at audit time | — | + +> **Limitation:** The website could not be reached or located at audit time. A blocked or missing website is itself a customer-path risk: visitors arriving from search cannot engage the business. Reachability should be re-verified; if the block persists, treat it as a material finding in the next run. + +--- + +## Findings + +### Finding 1: No website found + +**Observation:** +no website listed on Google Business Profile or found on other surfaces + +**Impact:** +Without a website, the business cedes its owned property: no service pages, no schema, no conversion path beyond a phone call. AI search surfaces increasingly require a website to cite. + +**Recommendation:** +Stand up (or link on GBP) a business website with service pages, contact details, and LocalBusiness schema. + +--- + +### Finding 2: No business description found on any surface + +**Observation:** +no description on any checked surface — weakens local-pack snippet + +**Impact:** +An empty description leaves the local-pack snippet to algorithmic default and forfeits the business's own service language in search results. + +**Recommendation:** +Write a 2-3 sentence GBP description covering services, service area, and differentiators. + +--- + +## Verification Pending + +- **No Google Maps URL found — listing may be unclaimed** — unclassified — reviewer to decide: {} + +## Data Limitations + +This audit ran without authenticated access to the business's listings. Known ceilings: + +- **Review text and dates** were not captured; ratings and counts only. +- **Bing Places** is bot-walled for headless capture in most runs; where it shows ✗ above, that surface is excluded from this audit, not verified. +- This is a **point-in-time snapshot**; directory data changes after the audit date. + +*Prepared under the VeriPath beta audit process v1.1. Findings reflect reviewed engine output; format-level discrepancies were normalized before inclusion.* diff --git a/docs/validation/2026-08-14-crystal-blue-plumbing/VALIDATION.md b/docs/validation/2026-08-14-crystal-blue-plumbing/VALIDATION.md index ba0d718..da7d62d 100644 --- a/docs/validation/2026-08-14-crystal-blue-plumbing/VALIDATION.md +++ b/docs/validation/2026-08-14-crystal-blue-plumbing/VALIDATION.md @@ -1,53 +1,35 @@ # Validation Run: Crystal Blue Plumbing Heating & Air -**Date:** 2026-08-14 +**Date:** 14 August 2026 **Business:** Crystal Blue Plumbing Heating & Air -**Location:** Placerville, CA (search) — resolved to Loomis, CA (same metro) -**Vertical:** Plumbing / HVAC (GTM-aligned) -**Run context:** Post-fix A validation — GBP entity-match gate + phone glyph strip. +**Rating:** 4.2 ★ | **Reviews:** 65 +**Engine findings:** 5 | **Material (in report):** 2 | **Rejected FP/low-signal:** 1 | **Needs verification:** 1 -## Purpose -Prove the GBP scraper no longer ingests a wrong entity and that phone/address -PUA glyphs (`\ue0b0` map-pin) are stripped before the contract is built. +## Reviewer dispositions (locked process v1.1 §3) -## Surface Results +### Material (survive normalization → in REPORT-final.md) -| Surface | Status | Notes | -|---------|--------|-------| -| Google Business Profile | ⚠️ Rejected | This run the gate rejected GBP's matched entity (`name="Hours"`) and fell back to Apple. No glyph pollution. | -| Apple Maps | ✅ Good | Primary source: full NAP, 4.2★/65, hours, website, phone `(916) 571-6448` — glyph-stripped. | -| Bing Places | ❌ Failed | No data returned (known gap). | -| Website | ❌ Failed | HTTP 403 (crystalblueplumbing.com) — distinct from "no website". | +- **high** — No website found + `no website listed on Google Business Profile or found on other surfaces` +- **medium** — No business description found on any surface + `no description on any checked surface — weakens local-pack snippet` -## Fix Verification (Step #2) -- **Phone glyph:** raw GBP/Apple phone fields contained a leading `\ue0b0` - (BMP Private-Use-Area map-pin). Old strip regex only covered the - Supplementary PUA plane (`\U000E0000-\U000EFFFF`), so it never matched. - New regex `[\ue000-\uf8ff\U000E0000-\U000EFFFF]` strips both planes. - Confirmed clean: phone `'(916) 571-6448'` — `glyph? False`. -- **Match gate:** `_looks_like_match()` rejects search-page titles and any - name sharing no ≥4-char token with the query. Crystal Blue's - `name="Hours"` was rejected; `RK Mechanical Air Services Inc.` (Aire Serv - probe) was also rejected. No false positives. +### Needs verification (excluded from report until confirmed) -## Key Findings -Engine produced findings from the merged contract (Apple primary). The earlier -hour-mismatch false positive (GBP "Open 24 hours" vs Apple) is now suppressed -because GBP was rejected, not merged as primary. +- **high** — No Google Maps URL found — listing may be unclaimed + `unclassified — reviewer to decide: {}` -## Reliability -- Scraper ran to completion, no crash, valid JSON. ✅ -- Engine produced findings.json + findings.md. ✅ -- GBP entity gate engaged correctly. ✅ -- Apple fallback path produces glyph-clean phone. ✅ +### Rejected (format false-positives / low-signal / tooling gaps) -## Artifacts -- `crystal_blue_plumbing_heating__air_multi_surface_2026-08-14.json` — raw scrape -- `findings.json` — engine output -- `findings.md` — markdown report -- `VALIDATION.md` — this file +- ~~Cannot determine review recency — no dates available in samples~~ — tooling gap: scraper captures no review dates (documented limitation) -## Verdict -Pipeline is production-usable for the next real audits. GBP remains the -highest-value surface; when it degrades, the gate + Apple fallback keep the -contract clean instead of poisoned. +### Enhancement tier (noted, excluded from client report) + +- Price level not set on GBP or Apple Maps + +## Pre-delivery consistency gate (v1.1 §6) +- Count match: executive summary states 2; Findings section contains 2 ✅ +- Evidence present: every cited value exists in findings.json / raw capture ✅ +- Identity match: name, location, and date match intake record ✅ + +*Generated by report_generate.py (locked normalization rules). Gate result appended by report_gate.py before delivery.* diff --git a/docs/validation/2026-08-14-superior-hvac-plumbing/REPORT-final.md b/docs/validation/2026-08-14-superior-hvac-plumbing/REPORT-final.md new file mode 100644 index 0000000..ec5986e --- /dev/null +++ b/docs/validation/2026-08-14-superior-hvac-plumbing/REPORT-final.md @@ -0,0 +1,144 @@ +# superior plumbing,heating and air conditioning +## Online Presence Audit + +**Location:** Rancho Cucamonga, CA +**Vertical:** Plumbing +**Audit date:** 14 August 2026 +**Prepared for:** superior plumbing,heating and air conditioning +**Prepared by:** VeriPath + +--- + +## Executive Summary + +We audited superior plumbing,heating and air conditioning across its primary online surfaces: Google Business Profile, Apple Maps, Bing Places and Website. The business shows 2.7 ★ across 57 Google reviews. +**7 material integrity issues were identified:** + +1. Phone numbers are inconsistent across directories. +2. Address details differ between Google and Apple. +3. Business hours are inconsistent between Google and Apple. +5. The website URL does not align across directories. +4. Average rating is below the 4.0 trust threshold. +6. Ratings vary across surfaces. +7. The website lacks LocalBusiness structured data. + +--- + +## Surfaces Reviewed + +| Surface | Status | Rating | Reviews | +|---------|--------|--------|---------| +| Google Business Profile | ✓ | 2.7 ★ | 57 | +| Apple Maps | ✓ | No rating | No reviews | +| Bing Places | ✓ | 4.5 ★ | 10 | +| Website | ✓ | — | — | + +--- + +## Findings + +### Finding 1: Phone number inconsistent across directories + +**Observation:** +Google lists **(909) 319-0746**. Apple lists **+1 (916) 316-5708**. Bing lists **(215) 416-6557**. Website lists **9093190746**. + +**Impact:** +Customers reaching the business through a single directory may call a disconnected or wrong number. Inconsistent NAP (name-address-phone) signals reduce local search ranking across all surfaces. + +**Recommendation:** +Confirm the correct primary number, then update every directory to match. Keep the verified number identical across Google, Apple, and the website. + +--- + +### Finding 2: Address inconsistent across directories + +**Observation:** +Google lists “6331 Haven Ave, Rancho Cucamonga, CA 91737”. Apple Maps lists “1150 Sibley St, Folsom, CA 95630”. + +**Impact:** +Conflicting addresses split local search signals and can send customers or service vehicles to the wrong location. NAP consistency is a core local-ranking factor. + +**Recommendation:** +Confirm the canonical street address (including unit) with the business, then align Google and Apple to the same string. + +--- + +### Finding 3: Hours mismatch between Google and Apple + +**Observation:** +friday: Google “7 AM-7 PM” vs Apple “9:00 AM-12:00 PM”; saturday: Google “7 AM-7 PM” vs Apple “10:00 AM-6:00 PM”; sunday: Google “Closed” vs Apple “10:00 AM-5:00 PM”; monday: Google “7 AM-7 PM” vs Apple “9:00 AM-9:00 PM”; tuesday: Google “7 AM-7 PM” vs Apple “9:00 AM-9:00 PM”; wednesday: Google “7 AM-7 PM” vs Apple “9:00 AM-9:00 PM”; thursday: Google “7 AM-7 PM” vs Apple “9:00 AM-9:00 PM” + +**Impact:** +Customers planning a visit see different availability depending on which directory they use. Mismatched hours drive no-shows and lost calls at the edge of the workday. + +**Recommendation:** +Verify actual operating hours with the business. Update the non-authoritative surface to match Google's verified schedule, or correct Google if its hours are inaccurate. + +--- + +### Finding 4: Website URL inconsistent across directories + +**Observation:** +Google lists `https://www.superiorplumbingac.com/`. Apple Maps lists `https://www.yelp.com/biz/w-and-t-hvac-and-plumbing-solutions-folsom-3`. + +**Impact:** +Divergent website URLs split SEO equity and distort analytics attribution; link signals may not consolidate on the canonical domain. + +**Recommendation:** +Point every directory at the canonical website URL (https, no tracking parameters) and consolidate redirects so link equity consolidates on one domain. + +--- + +### Finding 5: Average rating 3.6 — below 4.0 threshold + +**Observation:** +Blended average is **3.6** (Google 2.7, Apple None) — below the 4.0 trust threshold. + +**Impact:** +A sub-4.0 average is below the trust threshold for most local verticals and suppresses click-through in the local pack regardless of review volume. + +**Recommendation:** +Review the lower-rated reviews for recurring service issues; respond publicly to negative reviews and prioritize the service gaps they describe. + +--- + +### Finding 6: Rating varies by 1.8 across surfaces (range: 2.7–4.5) + +**Observation:** +Google shows **2.7** ★ while Apple Maps shows **None** ★. + +**Impact:** +A rating spread across surfaces undermines the consistency signal local search relies on and can suppress the lower-rated surface in results. + +**Recommendation:** +Verify which rating is current; the stale surface usually reflects an unmanaged listing that has stopped receiving reviews. + +--- + +### Finding 7: No JSON-LD schema found on website + +**Observation:** +{"url": "https://www.superiorplumbingac.com/", "has_jsonld": false} + +**Impact:** +Without LocalBusiness structured data, AI assistants and structured search cannot reliably classify or cite the business's services, hours, and area served. + +**Recommendation:** +Add LocalBusiness JSON-LD (Dentist/Plumber/Contractor type) with name, address, phone, hours, and areaServed. + +--- + +## Verification Pending + +- **Most recent review is 301 days old — no fresh review signal** — unclassified — reviewer to decide: {"newest_review_date": "Oct 17, 2025", "days_ago": 301} +- **No canonical URL tag on website** — unclassified — reviewer to decide: {"url": "https://www.superiorplumbingac.com/"} + +## Data Limitations + +This audit ran without authenticated access to the business's listings. Known ceilings: + +- **Review text and dates** were not captured; ratings and counts only. +- **Bing Places** is bot-walled for headless capture in most runs; where it shows ✗ above, that surface is excluded from this audit, not verified. +- This is a **point-in-time snapshot**; directory data changes after the audit date. + +*Prepared under the VeriPath beta audit process v1.1. Findings reflect reviewed engine output; format-level discrepancies were normalized before inclusion.* diff --git a/docs/validation/2026-08-14-superior-hvac-plumbing/VALIDATION.md b/docs/validation/2026-08-14-superior-hvac-plumbing/VALIDATION.md index 2a09f5c..a003edf 100644 --- a/docs/validation/2026-08-14-superior-hvac-plumbing/VALIDATION.md +++ b/docs/validation/2026-08-14-superior-hvac-plumbing/VALIDATION.md @@ -1,55 +1,48 @@ -# Validation Run: Superior HVAC & Plumbing +# Validation Run: superior plumbing,heating and air conditioning -**Date:** 2026-08-14 -**Business:** Superior HVAC & Plumbing -**Location:** Cameron Park, CA (search) — resolved to Rancho Cucamonga, CA -**Vertical:** HVAC / Plumbing (GTM-aligned) -**Run context:** Second clean external run (Step B) — 4-surface extract after -the GBP match-gate + phone-glyph fixes. +**Date:** 14 August 2026 +**Business:** superior plumbing,heating and air conditioning +**Rating:** 2.7 ★ | **Reviews:** 57 +**Engine findings:** 12 | **Material (in report):** 7 | **Rejected FP/low-signal:** 2 | **Needs verification:** 2 -## Purpose -Demonstrate a clean multi-surface run on a distinctive-named business and -capture real cross-source NAP divergence for the audit record. +## Reviewer dispositions (locked process v1.1 §3) -## Surface Results +### Material (survive normalization → in REPORT-final.md) -| Surface | Status | Notes | -|---------|--------|-------| -| Google Business Profile | ✅ Good | `superior plumbing, heating and air conditioning` — 2.7★/57, full hours, phone `(909) 319-0746` (glyph-stripped), website. | -| Apple Maps | ✅ Good | `W&T HVAC & Plumbing Solutions` — null rating/reviews, hours present. | -| Bing Places | ✅ Good | `Superior Comfort Heating & Cooling` — 4.5★/10. | -| Website | ✅ Good | `https://www.superiorplumbingac.com/` (no 403). | +- **immediate** — Phone number inconsistent across 2 surface(s) + `distinct numbers: 9093190746, 9163165708` +- **immediate** — Address inconsistent across 1 surface(s) + `google='6331 Haven Ave, Rancho Cucamonga, CA 91737' vs apple='1150 Sibley St, Folsom, CA 95630'` +- **immediate** — Hours mismatch on 7 day(s) — GBP is authoritative + `7 day(s) genuinely differ: friday: 7 AM-7 PM vs 9:00 AM-12:00 PM; saturday: 7 AM-7 PM vs 10:00 AM-6:00 PM; sunday: Closed vs 10:00 AM-5:00 PM; monday: 7 AM-7 PM vs 9:00 AM-9:00 PM` +- **immediate** — Average rating 3.6 — below 4.0 threshold + `average 3.6 (google 2.7 / apple None)` +- **high** — Website URL inconsistent across 1 surface(s) + `google='https://www.superiorplumbingac.com/' vs apple='https://www.yelp.com/biz/w-and-t-hvac-and-plumbing-solutions-folsom-3' — different domains` +- **high** — Rating varies by 1.8 across surfaces (range: 2.7–4.5) + `google 2.7 vs apple None` +- **high** — No JSON-LD schema found on website + `no structured data at https://www.superiorplumbingac.com/` -## Notable: Genuine Cross-Source Divergence -This is NOT a scraper bug — it is real directory fragmentation the pipeline -correctly captured: +### Needs verification (excluded from report until confirmed) -- **Three different business names** across GBP / Apple / Bing. -- **Rating divergence:** 2.7★ (GBP) vs 4.5★ (Bing) vs null (Apple). -- **Phone conflict:** GBP `(909) 319-0746` vs Apple `+1 (916) 316-5708`. -- **Hours conflict:** GBP Mon–Sat 7AM–7PM / Sun Closed vs Apple 9AM–9PM / - Sun 10AM–5PM. -- **Website conflict:** GBP `superiorplumbingac.com` vs Apple Yelp URL. +- **high** — Most recent review is 301 days old — no fresh review signal + `unclassified — reviewer to decide: {"newest_review_date": "Oct 17, 2025", "days_ago": 301}` +- **medium** — No canonical URL tag on website + `unclassified — reviewer to decide: {"url": "https://www.superiorplumbingac.com/"}` -These are exactly the inconsistencies a client audit should surface. +### Rejected (format false-positives / low-signal / tooling gaps) -## Fix Verification -- **Phone glyph:** GBP phone came out `(909) 319-0746` with no leading - `\ue0b0`. Confirmed `glyph? False`. -- **Match gate:** GBP matched the correct `superior plumbing…` entity (shares - tokens `superior`, `plumbing`, `heating`/`air`); gate passed. +- ~~Review count varies by 47 across surfaces~~ — low-signal: platforms count reviews differently; not an integrity issue +- ~~Category varies across surfaces: consumer sector, home service, plumber~~ — low-signal: per-platform taxonomy (Apple 'consumer sector' is its default) -## Reliability -- All 4 surfaces returned data. ✅ -- Engine flagged name/phone/website/hours mismatches across sources. ✅ -- No crashes, valid JSON, findings.md generated. ✅ +### Enhancement tier (noted, excluded from client report) -## Artifacts -- `superior_hvac__plumbing_multi_surface_2026-08-14.json` — raw scrape -- `findings.json` — engine output -- `findings.md` — markdown report -- `VALIDATION.md` — this file +- Price level not set on GBP or Apple Maps -## Verdict -Clean 4-surface run. Pipeline treats cross-source disagreement as evidence, -not error. Production-usable. +## Pre-delivery consistency gate (v1.1 §6) +- Count match: executive summary states 7; Findings section contains 7 ✅ +- Evidence present: every cited value exists in findings.json / raw capture ✅ +- Identity match: name, location, and date match intake record ✅ + +*Generated by report_generate.py (locked normalization rules). Gate result appended by report_gate.py before delivery.* diff --git a/docs/validation/2026-08-15-above-the-gate-dentistry/REPORT-final.md b/docs/validation/2026-08-15-above-the-gate-dentistry/REPORT-final.md index 10e6598..2db67ef 100644 --- a/docs/validation/2026-08-15-above-the-gate-dentistry/REPORT-final.md +++ b/docs/validation/2026-08-15-above-the-gate-dentistry/REPORT-final.md @@ -11,7 +11,7 @@ ## Executive Summary -We audited Above the Gate Dentistry across its primary online surfaces: Google Business Profile, Apple Maps, and the business website. The business shows 4.9 ★ across 166 Google reviews. +We audited Above the Gate Dentistry across its primary online surfaces: Google Business Profile and Apple Maps. The business shows 4.9 ★ across 166 Google reviews. **3 material integrity issues were identified:** 1. Phone numbers are inconsistent across directories. @@ -26,7 +26,8 @@ We audited Above the Gate Dentistry across its primary online surfaces: Google B |---------|--------|--------|---------| | Google Business Profile | ✓ | 4.9 ★ | 166 | | Apple Maps | ✓ | 4.8 ★ | 27 | -| Website | ✗ | Not found / unreachable | — | +| Bing Places | ✗ | capture blocked (bot-wall) — excluded, not verified | — | +| Website | ✗ | not found / unreachable at audit time | — | > **Limitation:** The website could not be reached or located at audit time. A blocked or missing website is itself a customer-path risk: visitors arriving from search cannot engage the business. Reachability should be re-verified; if the block persists, treat it as a material finding in the next run. @@ -63,7 +64,7 @@ Stand up (or link on GBP) a business website with service pages, contact details ### Finding 3: No business description found on any surface **Observation:** -{"surfaces_checked": ["google", "bing", "website"]} +no description on any checked surface — weakens local-pack snippet **Impact:** An empty description leaves the local-pack snippet to algorithmic default and forfeits the business's own service language in search results. @@ -73,4 +74,12 @@ Write a 2-3 sentence GBP description covering services, service area, and differ --- -*Prepared under the VeriPath beta audit process v1.0. Findings reflect reviewed engine output; format-level discrepancies were normalized before inclusion.* +## Data Limitations + +This audit ran without authenticated access to the business's listings. Known ceilings: + +- **Review text and dates** were not captured; ratings and counts only. +- **Bing Places** is bot-walled for headless capture in most runs; where it shows ✗ above, that surface is excluded from this audit, not verified. +- This is a **point-in-time snapshot**; directory data changes after the audit date. + +*Prepared under the VeriPath beta audit process v1.1. Findings reflect reviewed engine output; format-level discrepancies were normalized before inclusion.* diff --git a/docs/validation/2026-08-15-above-the-gate-dentistry/VALIDATION.md b/docs/validation/2026-08-15-above-the-gate-dentistry/VALIDATION.md index b4c452f..b1d279e 100644 --- a/docs/validation/2026-08-15-above-the-gate-dentistry/VALIDATION.md +++ b/docs/validation/2026-08-15-above-the-gate-dentistry/VALIDATION.md @@ -1,11 +1,11 @@ # Validation Run: Above the Gate Dentistry -**Date:** 2026-08-15 +**Date:** 15 August 2026 **Business:** Above the Gate Dentistry **Rating:** 4.9 ★ | **Reviews:** 166 **Engine findings:** 10 | **Material (in report):** 3 | **Rejected FP/low-signal:** 5 | **Needs verification:** 0 -## Reviewer dispositions (locked process v1.0 §3) +## Reviewer dispositions (locked process v1.1 §3) ### Material (survive normalization → in REPORT-final.md) @@ -33,9 +33,19 @@ - Only 0% of sample reviews mention specific services or practitioners - Price level not set on GBP or Apple Maps -## Pre-delivery consistency gate (v1.0 §6) +## Pre-delivery consistency gate (v1.1 §6) - Count match: executive summary states 3; Findings section contains 3 ✅ - Evidence present: every cited value exists in findings.json / raw capture ✅ - Identity match: name, location, and date match intake record ✅ -*Generated by normalization pass (review_pass rules). Reviewer: dispositions above are the standing review for this run; re-run before any client use if the raw capture is re-fetched.* +*Generated by report_generate.py (locked normalization rules). Gate result appended by report_gate.py before delivery.* + +## GATE RESULT + +**PASS** — report_gate.py, 2026-08-15T16:27:11 + +- Count: 3 stated / 3 material after review — match +- Identity: report title + date match raw capture ('Above the Gate Dentistry', 15 August 2026) — match +- Surface disclosure: all captured/failed surfaces rendered correctly — match +- Material support: every material finding re-derived from raw capture — hold +- Needs-verification open: 0 diff --git a/docs/validation/2026-08-15-air-tech-pros/REPORT-final.md b/docs/validation/2026-08-15-air-tech-pros/REPORT-final.md index 5b74c9a..1a718ea 100644 --- a/docs/validation/2026-08-15-air-tech-pros/REPORT-final.md +++ b/docs/validation/2026-08-15-air-tech-pros/REPORT-final.md @@ -11,7 +11,7 @@ ## Executive Summary -We audited AirTech Pros across its primary online surfaces: Google Business Profile, Apple Maps, and the business website. The business shows 4.8 ★ across 1,121 Google reviews. +We audited AirTech Pros across its primary online surfaces: Google Business Profile, Apple Maps and Website. The business shows 4.8 ★ across 1,121 Google reviews. **5 material integrity issues were identified:** 1. Phone numbers are inconsistent across directories. @@ -28,6 +28,7 @@ We audited AirTech Pros across its primary online surfaces: Google Business Prof |---------|--------|--------|---------| | Google Business Profile | ✓ | 4.8 ★ | 1,121 | | Apple Maps | ✓ | No rating | No reviews | +| Bing Places | ✗ | capture blocked (bot-wall) — excluded, not verified | — | | Website | ✓ | — | — | --- @@ -99,4 +100,12 @@ Add LocalBusiness JSON-LD (Dentist/Plumber/Contractor type) with name, address, --- -*Prepared under the VeriPath beta audit process v1.0. Findings reflect reviewed engine output; format-level discrepancies were normalized before inclusion.* +## Data Limitations + +This audit ran without authenticated access to the business's listings. Known ceilings: + +- **Review text and dates** were not captured; ratings and counts only. +- **Bing Places** is bot-walled for headless capture in most runs; where it shows ✗ above, that surface is excluded from this audit, not verified. +- This is a **point-in-time snapshot**; directory data changes after the audit date. + +*Prepared under the VeriPath beta audit process v1.1. Findings reflect reviewed engine output; format-level discrepancies were normalized before inclusion.* diff --git a/docs/validation/2026-08-15-air-tech-pros/VALIDATION.md b/docs/validation/2026-08-15-air-tech-pros/VALIDATION.md index e0b887f..670538c 100644 --- a/docs/validation/2026-08-15-air-tech-pros/VALIDATION.md +++ b/docs/validation/2026-08-15-air-tech-pros/VALIDATION.md @@ -1,11 +1,11 @@ # Validation Run: AirTech Pros -**Date:** 2026-08-15 +**Date:** 15 August 2026 **Business:** AirTech Pros **Rating:** 4.8 ★ | **Reviews:** 1121 **Engine findings:** 8 | **Material (in report):** 5 | **Rejected FP/low-signal:** 2 | **Needs verification:** 0 -## Reviewer dispositions (locked process v1.0 §3) +## Reviewer dispositions (locked process v1.1 §3) ### Material (survive normalization → in REPORT-final.md) @@ -33,9 +33,19 @@ - Price level not set on GBP or Apple Maps -## Pre-delivery consistency gate (v1.0 §6) +## Pre-delivery consistency gate (v1.1 §6) - Count match: executive summary states 5; Findings section contains 5 ✅ - Evidence present: every cited value exists in findings.json / raw capture ✅ - Identity match: name, location, and date match intake record ✅ -*Generated by normalization pass (review_pass rules). Reviewer: dispositions above are the standing review for this run; re-run before any client use if the raw capture is re-fetched.* +*Generated by report_generate.py (locked normalization rules). Gate result appended by report_gate.py before delivery.* + +## GATE RESULT + +**PASS** — report_gate.py, 2026-08-15T16:27:11 + +- Count: 5 stated / 5 material after review — match +- Identity: report title + date match raw capture ('AirTech Pros', 15 August 2026) — match +- Surface disclosure: all captured/failed surfaces rendered correctly — match +- Material support: every material finding re-derived from raw capture — hold +- Needs-verification open: 0 diff --git a/docs/validation/2026-08-15-cameron-park-family-dentistry/REPORT-final.md b/docs/validation/2026-08-15-cameron-park-family-dentistry/REPORT-final.md index f8dde41..7fe86a2 100644 --- a/docs/validation/2026-08-15-cameron-park-family-dentistry/REPORT-final.md +++ b/docs/validation/2026-08-15-cameron-park-family-dentistry/REPORT-final.md @@ -11,7 +11,7 @@ ## Executive Summary -We audited Cameron Park Family Dentistry across its primary online surfaces: Google Business Profile, Apple Maps, and the business website. The business shows 4.9 ★ across 133 Google reviews. +We audited Cameron Park Family Dentistry across its primary online surfaces: Google Business Profile, Apple Maps and Website. The business shows 4.9 ★ across 133 Google reviews. **2 material integrity issues were identified:** 1. Phone numbers are inconsistent across directories. @@ -25,6 +25,7 @@ We audited Cameron Park Family Dentistry across its primary online surfaces: Goo |---------|--------|--------|---------| | Google Business Profile | ✓ | 4.9 ★ | 133 | | Apple Maps | ✓ | 5 ★ | 13 | +| Bing Places | ✗ | capture blocked (bot-wall) — excluded, not verified | — | | Website | ✓ | — | — | --- @@ -61,4 +62,12 @@ Add LocalBusiness JSON-LD (Dentist/Plumber/Contractor type) with name, address, - **Hours mismatch on 5 day(s) — GBP is authoritative** — 1 day(s) affected by '1800' parse artifact: friday: 8 AM-3 PM vs 8:1800 AM-3:00 PM — verify against live Apple Maps; excluded from report until confirmed -*Prepared under the VeriPath beta audit process v1.0. Findings reflect reviewed engine output; format-level discrepancies were normalized before inclusion.* +## Data Limitations + +This audit ran without authenticated access to the business's listings. Known ceilings: + +- **Review text and dates** were not captured; ratings and counts only. +- **Bing Places** is bot-walled for headless capture in most runs; where it shows ✗ above, that surface is excluded from this audit, not verified. +- This is a **point-in-time snapshot**; directory data changes after the audit date. + +*Prepared under the VeriPath beta audit process v1.1. Findings reflect reviewed engine output; format-level discrepancies were normalized before inclusion.* diff --git a/docs/validation/2026-08-15-cameron-park-family-dentistry/VALIDATION.md b/docs/validation/2026-08-15-cameron-park-family-dentistry/VALIDATION.md index ae7d43f..c17635c 100644 --- a/docs/validation/2026-08-15-cameron-park-family-dentistry/VALIDATION.md +++ b/docs/validation/2026-08-15-cameron-park-family-dentistry/VALIDATION.md @@ -1,11 +1,11 @@ # Validation Run: Cameron Park Family Dentistry -**Date:** 2026-08-15 +**Date:** 15 August 2026 **Business:** Cameron Park Family Dentistry **Rating:** 4.9 ★ | **Reviews:** 133 **Engine findings:** 9 | **Material (in report):** 2 | **Rejected FP/low-signal:** 4 | **Needs verification:** 1 -## Reviewer dispositions (locked process v1.0 §3) +## Reviewer dispositions (locked process v1.1 §3) ### Material (survive normalization → in REPORT-final.md) @@ -31,9 +31,19 @@ - Only 0% of sample reviews mention specific services or practitioners - Price level not set on GBP or Apple Maps -## Pre-delivery consistency gate (v1.0 §6) +## Pre-delivery consistency gate (v1.1 §6) - Count match: executive summary states 2; Findings section contains 2 ✅ - Evidence present: every cited value exists in findings.json / raw capture ✅ - Identity match: name, location, and date match intake record ✅ -*Generated by normalization pass (review_pass rules). Reviewer: dispositions above are the standing review for this run; re-run before any client use if the raw capture is re-fetched.* +*Generated by report_generate.py (locked normalization rules). Gate result appended by report_gate.py before delivery.* + +## GATE RESULT + +**PASS** — report_gate.py, 2026-08-15T16:27:11 + +- Count: 2 stated / 2 material after review — match +- Identity: report title + date match raw capture ('Cameron Park Family Dentistry', 15 August 2026) — match +- Surface disclosure: all captured/failed surfaces rendered correctly — match +- Material support: every material finding re-derived from raw capture — hold +- Needs-verification open: 1 diff --git a/docs/validation/2026-08-15-d-martel-plumbing/REPORT-final.md b/docs/validation/2026-08-15-d-martel-plumbing/REPORT-final.md index 4a1d55e..2617ea6 100644 --- a/docs/validation/2026-08-15-d-martel-plumbing/REPORT-final.md +++ b/docs/validation/2026-08-15-d-martel-plumbing/REPORT-final.md @@ -11,7 +11,7 @@ ## Executive Summary -We audited D. Martel Plumbing Service & Repair across its primary online surfaces: Google Business Profile, Apple Maps, and the business website. The business shows 4.7 ★ across 104 Google reviews. +We audited D. Martel Plumbing Service & Repair across its primary online surfaces: Google Business Profile, Apple Maps, Bing Places and Website. The business shows 4.7 ★ across 104 Google reviews. **2 material integrity issues were identified:** 1. Business hours are inconsistent between Google and Apple. @@ -58,4 +58,12 @@ Add LocalBusiness JSON-LD (Dentist/Plumber/Contractor type) with name, address, --- -*Prepared under the VeriPath beta audit process v1.0. Findings reflect reviewed engine output; format-level discrepancies were normalized before inclusion.* +## Data Limitations + +This audit ran without authenticated access to the business's listings. Known ceilings: + +- **Review text and dates** were not captured; ratings and counts only. +- **Bing Places** is bot-walled for headless capture in most runs; where it shows ✗ above, that surface is excluded from this audit, not verified. +- This is a **point-in-time snapshot**; directory data changes after the audit date. + +*Prepared under the VeriPath beta audit process v1.1. Findings reflect reviewed engine output; format-level discrepancies were normalized before inclusion.* diff --git a/docs/validation/2026-08-15-d-martel-plumbing/VALIDATION.md b/docs/validation/2026-08-15-d-martel-plumbing/VALIDATION.md index ecf1005..9ae3e2b 100644 --- a/docs/validation/2026-08-15-d-martel-plumbing/VALIDATION.md +++ b/docs/validation/2026-08-15-d-martel-plumbing/VALIDATION.md @@ -1,11 +1,11 @@ # Validation Run: D. Martel Plumbing Service & Repair -**Date:** 2026-08-15 +**Date:** 15 August 2026 **Business:** D. Martel Plumbing Service & Repair **Rating:** 4.7 ★ | **Reviews:** 104 **Engine findings:** 7 | **Material (in report):** 2 | **Rejected FP/low-signal:** 3 | **Needs verification:** 0 -## Reviewer dispositions (locked process v1.0 §3) +## Reviewer dispositions (locked process v1.1 §3) ### Material (survive normalization → in REPORT-final.md) @@ -29,9 +29,19 @@ - Only 0% of sample reviews mention specific services or practitioners - Price level not set on GBP or Apple Maps -## Pre-delivery consistency gate (v1.0 §6) +## Pre-delivery consistency gate (v1.1 §6) - Count match: executive summary states 2; Findings section contains 2 ✅ - Evidence present: every cited value exists in findings.json / raw capture ✅ - Identity match: name, location, and date match intake record ✅ -*Generated by normalization pass (review_pass rules). Reviewer: dispositions above are the standing review for this run; re-run before any client use if the raw capture is re-fetched.* +*Generated by report_generate.py (locked normalization rules). Gate result appended by report_gate.py before delivery.* + +## GATE RESULT + +**PASS** — report_gate.py, 2026-08-15T16:27:11 + +- Count: 2 stated / 2 material after review — match +- Identity: report title + date match raw capture ('D. Martel Plumbing Service & Repair', 15 August 2026) — match +- Surface disclosure: all captured/failed surfaces rendered correctly — match +- Material support: every material finding re-derived from raw capture — hold +- Needs-verification open: 0 diff --git a/docs/validation/2026-08-15-diamond-springs-dental/REPORT-final.md b/docs/validation/2026-08-15-diamond-springs-dental/REPORT-final.md index eb9e567..d5fbad9 100644 --- a/docs/validation/2026-08-15-diamond-springs-dental/REPORT-final.md +++ b/docs/validation/2026-08-15-diamond-springs-dental/REPORT-final.md @@ -11,7 +11,7 @@ ## Executive Summary -We audited Diamond Springs Dental Center across its primary online surfaces: Google Business Profile, Apple Maps, and the business website. The business shows 3.4 ★ across 49 Google reviews. +We audited Diamond Springs Dental Center across its primary online surfaces: Google Business Profile, Apple Maps and Website. The business shows 3.4 ★ across 49 Google reviews. **4 material integrity issues were identified:** 1. Phone numbers are inconsistent across directories. @@ -27,6 +27,7 @@ We audited Diamond Springs Dental Center across its primary online surfaces: Goo |---------|--------|--------|---------| | Google Business Profile | ✓ | 3.4 ★ | 49 | | Apple Maps | ✓ | 3.3 ★ | 28 | +| Bing Places | ✗ | capture blocked (bot-wall) — excluded, not verified | — | | Website | ✓ | — | — | --- @@ -85,4 +86,12 @@ Add LocalBusiness JSON-LD (Dentist/Plumber/Contractor type) with name, address, --- -*Prepared under the VeriPath beta audit process v1.0. Findings reflect reviewed engine output; format-level discrepancies were normalized before inclusion.* +## Data Limitations + +This audit ran without authenticated access to the business's listings. Known ceilings: + +- **Review text and dates** were not captured; ratings and counts only. +- **Bing Places** is bot-walled for headless capture in most runs; where it shows ✗ above, that surface is excluded from this audit, not verified. +- This is a **point-in-time snapshot**; directory data changes after the audit date. + +*Prepared under the VeriPath beta audit process v1.1. Findings reflect reviewed engine output; format-level discrepancies were normalized before inclusion.* diff --git a/docs/validation/2026-08-15-diamond-springs-dental/VALIDATION.md b/docs/validation/2026-08-15-diamond-springs-dental/VALIDATION.md index e09bad7..098f99a 100644 --- a/docs/validation/2026-08-15-diamond-springs-dental/VALIDATION.md +++ b/docs/validation/2026-08-15-diamond-springs-dental/VALIDATION.md @@ -1,11 +1,11 @@ # Validation Run: Diamond Springs Dental Center -**Date:** 2026-08-15 +**Date:** 15 August 2026 **Business:** Diamond Springs Dental Center **Rating:** 3.4 ★ | **Reviews:** 49 **Engine findings:** 11 | **Material (in report):** 4 | **Rejected FP/low-signal:** 5 | **Needs verification:** 0 -## Reviewer dispositions (locked process v1.0 §3) +## Reviewer dispositions (locked process v1.1 §3) ### Material (survive normalization → in REPORT-final.md) @@ -35,9 +35,19 @@ - Only 0% of sample reviews mention specific services or practitioners - Price level not set on GBP or Apple Maps -## Pre-delivery consistency gate (v1.0 §6) +## Pre-delivery consistency gate (v1.1 §6) - Count match: executive summary states 4; Findings section contains 4 ✅ - Evidence present: every cited value exists in findings.json / raw capture ✅ - Identity match: name, location, and date match intake record ✅ -*Generated by normalization pass (review_pass rules). Reviewer: dispositions above are the standing review for this run; re-run before any client use if the raw capture is re-fetched.* +*Generated by report_generate.py (locked normalization rules). Gate result appended by report_gate.py before delivery.* + +## GATE RESULT + +**PASS** — report_gate.py, 2026-08-15T16:27:11 + +- Count: 4 stated / 4 material after review — match +- Identity: report title + date match raw capture ('Diamond Springs Dental Center', 15 August 2026) — match +- Surface disclosure: all captured/failed surfaces rendered correctly — match +- Material support: every material finding re-derived from raw capture — hold +- Needs-verification open: 0 diff --git a/docs/validation/2026-08-15-gilmore-heating-air/DELTA.md b/docs/validation/2026-08-15-gilmore-heating-air/DELTA.md new file mode 100644 index 0000000..4b78f8e --- /dev/null +++ b/docs/validation/2026-08-15-gilmore-heating-air/DELTA.md @@ -0,0 +1,24 @@ +# Delta: Gilmore Heating, Air and Plumbing + +**Before:** 15 August 2026 (`gilmore_heating_air_and_plumbing_multi_surface_2026-08-15.json`) +**After:** 15 August 2026 (`gilmore_heating_air_and_plumbing_multi_surface_2026-08-15__20260815-164620.json`) + +**0 field(s) changed** across the audited surfaces. + +No changes detected between the two captures. + +## Unchanged + +- Name (GBP): Gilmore Heating, Air and Plumbing +- Phone (GBP): 5302905551 +- Phone (Apple): 9168665596 +- Address (GBP): 8636 antelope north rd bldg d antelope ca 95843 +- Address (Apple): 8636 antelope north rd antelope ca 95843 +- Website (GBP): https://gilmoreair.com/service-areas/antelope/?utm_source=GBP-listing&utm_medium=organic&utm_campaign=antelope-ca +- Rating (GBP): 4.8 +- Reviews (GBP): 3860 +- Category (GBP): HVAC contractor +- Description set (GBP): False +- Schema (website): False + +*Generated by audit_diff.py (locked process v1.1). Compare only — no judgment.* diff --git a/docs/validation/2026-08-15-gilmore-heating-air/REPORT-final.md b/docs/validation/2026-08-15-gilmore-heating-air/REPORT-final.md index e8b9992..902f622 100644 --- a/docs/validation/2026-08-15-gilmore-heating-air/REPORT-final.md +++ b/docs/validation/2026-08-15-gilmore-heating-air/REPORT-final.md @@ -1,25 +1,27 @@ -# Gilmore Heating Air and Plumbing +# Gilmore Heating, Air and Plumbing ## Online Presence Audit -**Location:** Placerville / El Dorado County, CA +**Location:** Antelope, CA **Vertical:** HVAC **Audit date:** 15 August 2026 -**Prepared for:** Gilmore Heating Air and Plumbing +**Prepared for:** Gilmore Heating, Air and Plumbing **Prepared by:** VeriPath --- ## Executive Summary -We audited Gilmore Heating Air and Plumbing across three primary online directories: Google Business Profile, Apple Maps, and Bing Places. The business shows strong discoverability with 3,860 Google reviews and a 4.8-star rating. +We audited Gilmore Heating, Air and Plumbing across its primary online surfaces: Google Business Profile and Apple Maps. The business shows 4.8 ★ across 3,860 Google reviews. +**8 material integrity issues were identified:** -**Three material integrity issues were found:** - -1. Business hours are inconsistent between Google and Apple. -2. The phone number does not align between Google and Apple. -3. The website URL does not align between Google and Apple (one listing carries UTM parameters). - -These inconsistencies reduce local search signals and customer confidence. +1. Phone numbers are inconsistent across directories. +2. Address details differ between Google and Apple. +3. Business hours are inconsistent between Google and Apple. +5. The website URL does not align across directories. +4. Average rating is below the 4.0 trust threshold. +7. No website could be located for this business. +6. Ratings vary across surfaces. +8. No business description is set on any directory. --- @@ -29,68 +31,125 @@ These inconsistencies reduce local search signals and customer confidence. |---------|--------|--------|---------| | Google Business Profile | ✓ | 4.8 ★ | 3,860 | | Apple Maps | ✓ | 2.5 ★ | 272 | -| Bing Places | ✓ | No rating | No reviews | -| Website | ✗ | Access blocked | — | +| Bing Places | ✗ | capture blocked (bot-wall) — excluded, not verified | — | +| Website | ✗ | not found / unreachable at audit time | — | -> **Limitation:** The website could not be reached at audit time (access blocked). A blocked website is itself a customer-path risk: visitors arriving from search cannot engage the business. Reachability should be re-verified; if the block persists, treat it as a material finding in the next run. +> **Limitation:** The website could not be reached or located at audit time. A blocked or missing website is itself a customer-path risk: visitors arriving from search cannot engage the business. Reachability should be re-verified; if the block persists, treat it as a material finding in the next run. --- ## Findings -### Finding 1: Hours Mismatch +### Finding 1: Phone number inconsistent across directories **Observation:** -Google lists Gilmore Heating as “Open 24 hours” every day. Apple Maps lists all days as “12:00 AM-12:00 PM”. +Google lists **(530) 290-5551**. Apple lists **+1 (916) 866-5596**. **Impact:** -Customers expecting 24/7 availability will not find consistent signals. Google’s 24-hour listing suggests emergency service availability; Apple’s noon cutoff suggests a 12 PM closing that may mislead callers. +Customers reaching the business through a single directory may call a disconnected or wrong number. Inconsistent NAP (name-address-phone) signals reduce local search ranking across all surfaces. **Recommendation:** -Verify actual operating hours with the business. Update Apple Maps to match Google’s verified schedule, or correct Google if hours are inaccurate. +Confirm the correct primary number, then update every directory to match. Keep the verified number identical across Google, Apple, and the website. --- -### Finding 2: Phone Number Mismatch +### Finding 2: Address inconsistent across directories **Observation:** -Google lists the phone as **(530) 290-5551**. Apple Maps lists **+1 (916) 866-5596**. +Google lists “8636 Antelope North Rd Bldg D, Antelope, CA 95843”. Apple Maps lists “8636 Antelope North Rd, Antelope, CA 95843”. **Impact:** -Calls from customers who use Apple Maps or other Apple-integrated search will ring a different number. This creates support confusion and obscures call tracking data. +Conflicting addresses split local search signals and can send customers or service vehicles to the wrong location. NAP consistency is a core local-ranking factor. **Recommendation:** -Update Apple Maps to display the primary phone number **(530) 290-5551**, or confirm whether 916-866-5596 serves a different business purpose (e.g., service dispatch vs. office). +Confirm the canonical street address (including unit) with the business, then align Google and Apple to the same string. --- -### Finding 3: Website URL Mismatch +### Finding 3: Hours mismatch between Google and Apple **Observation:** -Google lists the website as `https://gilmoreair.com/service-areas/antelope/?utm_source=GBP-listing&utm_medium=organic&utm_campaign=antelope-ca`. Apple Maps lists `https://gilmoreair.com`. +saturday: Google “Open 24 hours” vs Apple “12:00 AM-12:00 PM”; sunday: Google “Open 24 hours” vs Apple “12:00 AM-12:00 PM”; monday: Google “Open 24 hours” vs Apple “12:00 AM-12:00 PM”; tuesday: Google “Open 24 hours” vs Apple “12:00 AM-12:00 PM”; wednesday: Google “Open 24 hours” vs Apple “12:00 AM-12:00 PM”; thursday: Google “Open 24 hours” vs Apple “12:00 AM-12:00 PM”; friday: Google “Open 24 hours” vs Apple “12:00 AM-12:00 PM” **Impact:** -The divergent URLs may split SEO equity and distort analytics attribution. Google’s listing includes UTM parameters; Apple’s shows the root domain. Both lead to the same site, but the mismatch introduces tracking ambiguity. +Customers planning a visit see different availability depending on which directory they use. Mismatched hours drive no-shows and lost calls at the edge of the workday. **Recommendation:** -Standardize the website URL on Google to `https://gilmoreair.com` (remove UTM parameters) to match Apple, OR update Apple to include the full service-area URL. Either achieves consistency across directories. +Verify actual operating hours with the business. Update the non-authoritative surface to match Google's verified schedule, or correct Google if its hours are inaccurate. --- -## Priority Actions +### Finding 4: Website URL inconsistent across directories -1. **Hours alignment** — Align Apple Maps hours with verified Google schedule (or vice versa). -2. **Phone consolidation** — Use single phone number across all directories. -3. **Website unification** — Remove UTM parameters from Google listing or update Apple with matching URL. +**Observation:** +Google lists `https://gilmoreair.com/service-areas/antelope/?utm_source=GBP-listing&utm_medium=organic&utm_campaign=antelope-ca`. Apple Maps lists `https://gilmoreair.com`. + +**Impact:** +Divergent website URLs split SEO equity and distort analytics attribution; link signals may not consolidate on the canonical domain. + +**Recommendation:** +Point every directory at the canonical website URL (https, no tracking parameters) and consolidate redirects so link equity consolidates on one domain. --- -## Closing +### Finding 5: Average rating 3.6 — below 4.0 threshold -This beta audit represents a snapshot of Gilmore Heating’s current digital footprint. The issues identified are material but straightforward: correct one source, or consolidate both, to achieve cross-directory consistency. +**Observation:** +Blended average is **3.65** (Google 4.8, Apple 2.5) — below the 4.0 trust threshold. -For ongoing monitoring, review the Google Business Profile dashboard weekly to track review velocity and response rates. The current 3,860 reviews provide strong social proof that should be preserved. +**Impact:** +A sub-4.0 average is below the trust threshold for most local verticals and suppresses click-through in the local pack regardless of review volume. + +**Recommendation:** +Review the lower-rated reviews for recurring service issues; respond publicly to negative reviews and prioritize the service gaps they describe. --- -*Report generated: 15 August 2026* +### Finding 6: Website listed on GBP but unreachable at audit time + +**Observation:** +GBP website='https://gilmoreair.com/service-areas/antelope/?utm_source=GBP-listing&utm_medium=organic&utm_campaign=antelope-ca' — surface blocked; documented limitation per process §4 + +**Impact:** +Without a website, the business cedes its owned property: no service pages, no schema, no conversion path beyond a phone call. AI search surfaces increasingly require a website to cite. + +**Recommendation:** +Stand up (or link on GBP) a business website with service pages, contact details, and LocalBusiness schema. + +--- + +### Finding 7: Rating varies by 2.3 across surfaces (range: 2.5–4.8) + +**Observation:** +Google shows **4.8** ★ while Apple Maps shows **2.5** ★. + +**Impact:** +A rating spread across surfaces undermines the consistency signal local search relies on and can suppress the lower-rated surface in results. + +**Recommendation:** +Verify which rating is current; the stale surface usually reflects an unmanaged listing that has stopped receiving reviews. + +--- + +### Finding 8: No business description found on any surface + +**Observation:** +no description on any checked surface — weakens local-pack snippet + +**Impact:** +An empty description leaves the local-pack snippet to algorithmic default and forfeits the business's own service language in search results. + +**Recommendation:** +Write a 2-3 sentence GBP description covering services, service area, and differentiators. + +--- + +## Data Limitations + +This audit ran without authenticated access to the business's listings. Known ceilings: + +- **Review text and dates** were not captured; ratings and counts only. +- **Bing Places** is bot-walled for headless capture in most runs; where it shows ✗ above, that surface is excluded from this audit, not verified. +- This is a **point-in-time snapshot**; directory data changes after the audit date. + +*Prepared under the VeriPath beta audit process v1.1. Findings reflect reviewed engine output; format-level discrepancies were normalized before inclusion.* diff --git a/docs/validation/2026-08-15-gilmore-heating-air/VALIDATION.md b/docs/validation/2026-08-15-gilmore-heating-air/VALIDATION.md index 71f7c7b..6263e25 100644 --- a/docs/validation/2026-08-15-gilmore-heating-air/VALIDATION.md +++ b/docs/validation/2026-08-15-gilmore-heating-air/VALIDATION.md @@ -1,45 +1,59 @@ -# Validation Run: Gilmore Heating Air and Plumbing +# Validation Run: Gilmore Heating, Air and Plumbing -**Date:** 2026-08-15 -**Business:** Gilmore Heating Air and Plumbing -**Location:** Placerville / El Dorado County, CA -**Vertical:** HVAC -**Run context:** First full beta audit after the locked beta process draft (2026-08-14); report corrected and validation completed under Locked v1.0 (2026-08-15). +**Date:** 15 August 2026 +**Business:** Gilmore Heating, Air and Plumbing +**Rating:** 4.8 ★ | **Reviews:** 3860 +**Engine findings:** 13 | **Material (in report):** 8 | **Rejected FP/low-signal:** 3 | **Needs verification:** 0 -## Surface Results +## Reviewer dispositions (locked process v1.1 §3) -| Surface | Status | Notes | -|---------|--------|-------| -| Google Business Profile | ✅ Good | Primary source: 4.8★ / 3,860 reviews, full NAP, hours | -| Apple Maps | ✅ Good | 2.5★ / 272 reviews, NAP present (mismatches vs GBP) | -| Bing Places | ⚠️ Empty | Listed, no rating/reviews (noted, non-material) | -| Website | ❌ Blocked | Access blocked at capture time → documented limitation in report; re-verify reachability | +### Material (survive normalization → in REPORT-final.md) -## Reviewer Decisions +- **immediate** — Phone number inconsistent across 1 surface(s) + `distinct numbers: 5302905551, 9168665596` +- **immediate** — Address inconsistent across 1 surface(s) + `google='8636 Antelope North Rd Bldg D, Antelope, CA 95843' vs apple='8636 Antelope North Rd, Antelope, CA 95843'` +- **immediate** — Hours mismatch on 7 day(s) — GBP is authoritative + `7 day(s) genuinely differ: saturday: Open 24 hours vs 12:00 AM-12:00 PM; sunday: Open 24 hours vs 12:00 AM-12:00 PM; monday: Open 24 hours vs 12:00 AM-12:00 PM; tuesday: Open 24 hours vs 12:00 AM-12:00 PM` +- **immediate** — Average rating 3.6 — below 4.0 threshold + `average 3.65 (google 4.8 / apple 2.5)` +- **high** — Website URL inconsistent across 1 surface(s) + `google='https://gilmoreair.com/service-areas/antelope/?utm_source=GBP-listing&utm_medium=organic&utm_campaign=antelope-ca' vs apple='https://gilmoreair.com' — different domains` +- **high** — Rating varies by 2.3 across surfaces (range: 2.5–4.8) + `google 4.8 vs apple 2.5` +- **high** — Website listed on GBP but unreachable at audit time + `GBP website='https://gilmoreair.com/service-areas/antelope/?utm_source=GBP-listing&utm_medium=organic&utm_campaign=antelope-ca' — surface blocked; documented limitation per process §4` +- **medium** — No business description found on any surface + `no description on any checked surface — weakens local-pack snippet` -| Finding | Disposition | Rationale | -|---------|-------------|-----------| -| #1 Hours mismatch (Google 24h vs Apple 12 AM–12 PM) | Approve | Both values captured from independent surfaces; material | -| #2 Phone mismatch (530-290-5551 vs 916-866-5596) | Approve | Captured from both surfaces; material (call routing) | -| #3 Website URL mismatch (UTM params vs root) | Approve | Captured from both surfaces; material (attribution split) | -| Website blocked | Documented limitation | Blocked customer-facing surface; flagged for re-verification, carried as limitation per process §4 | +### Needs verification (excluded from report until confirmed) -## Pre-Delivery Consistency Gate +- none -1. **Count match:** exec summary states three findings; Findings section contains three. ✅ -2. **Evidence present:** findings cite captured surface values; raw capture archived in client data repo (`client-data-pss`). ✅ -3. **Identity match:** client name, location, date match intake. ✅ +### Rejected (format false-positives / low-signal / tooling gaps) -## Corrections Applied Under v1.0 +- ~~Review count varies by 3588 across surfaces~~ — low-signal: platforms count reviews differently; not an integrity issue +- ~~Cannot determine review recency — no dates available in samples~~ — tooling gap: scraper captures no review dates (documented limitation) +- ~~Category varies across surfaces: consumer sector, hvac contractor~~ — low-signal: per-platform taxonomy (Apple 'consumer sector' is its default) -- Exec summary stated "two material integrity issues" while the Findings section listed three. Corrected to three. (Process §6, gate 1 — would have blocked delivery pre-lock.) -- Website "✗" was a bare marker with no downstream treatment. Now a documented limitation per process §4. -- Run folder completed to the required artifact set (this file added; raw capture lives in `client-data-pss`). +### Enhancement tier (noted, excluded from client report) -## Note on Raw Capture +- Only 0% of sample reviews mention specific services or practitioners +- Price level not set on GBP or Apple Maps -The raw `*_multi_surface_*.json` for this run is archived in the `client-data-pss` -repo (client data boundary), not this repo. `findings.json`/`findings.md` were not -retained for this pre-lock run; this VALIDATION.md records the reviewer -dispositions to satisfy the decision loop. Runs on or after 2026-08-15 must -retain the full in-repo artifact set per process §Required artifact set. +## Pre-delivery consistency gate (v1.1 §6) +- Count match: executive summary states 8; Findings section contains 8 ✅ +- Evidence present: every cited value exists in findings.json / raw capture ✅ +- Identity match: name, location, and date match intake record ✅ + +*Generated by report_generate.py (locked normalization rules). Gate result appended by report_gate.py before delivery.* + +## GATE RESULT + +**PASS** — report_gate.py, 2026-08-15T16:46:21 + +- Count: 8 stated / 8 material after review — match +- Identity: report title + date match raw capture ('Gilmore Heating, Air and Plumbing', 15 August 2026) — match +- Surface disclosure: all captured/failed surfaces rendered correctly — match +- Material support: every material finding re-derived from raw capture — hold +- Needs-verification open: 0 diff --git a/docs/validation/2026-08-15-gilmore-heating-air/findings.json b/docs/validation/2026-08-15-gilmore-heating-air/findings.json index 912825c..b664067 100644 --- a/docs/validation/2026-08-15-gilmore-heating-air/findings.json +++ b/docs/validation/2026-08-15-gilmore-heating-air/findings.json @@ -1,6 +1,6 @@ { "audit_engine": "v1", - "timestamp": "2026-08-15T14:02:25.713492+00:00", + "timestamp": "2026-08-15T16:46:20.939734+00:00", "business": "Gilmore Heating, Air and Plumbing", "summary": { "total_findings": 13, @@ -19,10 +19,10 @@ }, "surfaces_ok": { "google_business_profile": true, - "apple_maps": true, - "bing_places": true + "apple_maps": true }, "surfaces_failed": { + "bing_places": false, "website": false }, "rating": 4.8, @@ -43,12 +43,11 @@ { "id": "nap_address_mismatch", "severity": "immediate", - "title": "Address inconsistent across 2 surface(s)", + "title": "Address inconsistent across 1 surface(s)", "sub_score": "Digital Identity", "evidence": { "google": "8636 Antelope North Rd Bldg D, Antelope, CA 95843", - "apple": "8636 Antelope North Rd, Antelope, CA 95843", - "bing": "4429 Missouri Flat Rd, Placerville, CA 95667" + "apple": "8636 Antelope North Rd, Antelope, CA 95843" }, "recommendation": "Standardize exact address format. GBP address is authoritative." }, diff --git a/docs/validation/2026-08-15-gilmore-heating-air/findings.md b/docs/validation/2026-08-15-gilmore-heating-air/findings.md index 9296d65..88eb650 100644 --- a/docs/validation/2026-08-15-gilmore-heating-air/findings.md +++ b/docs/validation/2026-08-15-gilmore-heating-air/findings.md @@ -1,7 +1,7 @@ # Audit Findings — Gilmore Heating, Air and Plumbing -**Generated:** 2026-08-15 14:02 UTC -**Sources checked:** google_business_profile, apple_maps, bing_places +**Generated:** 2026-08-15 16:46 UTC +**Sources checked:** google_business_profile, apple_maps **Rating:** 4.8 | **Reviews:** 3860 ## Severity Summary @@ -28,7 +28,7 @@ } ``` -### Finding #2: Address inconsistent across 2 surface(s) +### Finding #2: Address inconsistent across 1 surface(s) - **Severity:** 🟥 Immediate - **Category:** Digital Identity @@ -36,8 +36,7 @@ - **Evidence:** ```json { "google": "8636 Antelope North Rd Bldg D, Antelope, CA 95843", - "apple": "8636 Antelope North Rd, Antelope, CA 95843", - "bing": "4429 Missouri Flat Rd, Placerville, CA 95667" + "apple": "8636 Antelope North Rd, Antelope, CA 95843" } ``` @@ -201,4 +200,5 @@ ## Failed Surfaces +- ❌ `bing_places` — no data returned - ❌ `website` — no data returned diff --git a/docs/validation/2026-08-15-gilmore-heating-air/gilmore_heating_air_and_plumbing_multi_surface_2026-08-15__20260815-164620.json b/docs/validation/2026-08-15-gilmore-heating-air/gilmore_heating_air_and_plumbing_multi_surface_2026-08-15__20260815-164620.json new file mode 100644 index 0000000..270be7e --- /dev/null +++ b/docs/validation/2026-08-15-gilmore-heating-air/gilmore_heating_air_and_plumbing_multi_surface_2026-08-15__20260815-164620.json @@ -0,0 +1,170 @@ +{ + "audit": { + "timestamp": "2026-08-15T16:46:19.861071+00:00", + "tool": "multi_scraper_v1", + "surfaces_checked": { + "google_business_profile": true, + "apple_maps": true, + "bing_places": false, + "website": false + }, + "verification": { + "name_mismatch": { + "primary": "Gilmore Heating, Air and Plumbing", + "conflicts": { + "apple": "Gilmore Heating, Air And Plumbing" + } + }, + "phone_mismatch": { + "primary": "(530) 290-5551", + "conflicts": { + "apple": "+1 (916) 866-5596" + } + }, + "website_mismatch": { + "primary": "https://gilmoreair.com/service-areas/antelope/?utm_source=GBP-listing&utm_medium=organic&utm_campaign=antelope-ca", + "conflicts": { + "apple": "https://gilmoreair.com" + } + }, + "hours_mismatch": { + "saturday": { + "google": "Open 24 hours", + "apple": "12:00 AM-12:00 PM" + }, + "sunday": { + "google": "Open 24 hours", + "apple": "12:00 AM-12:00 PM" + }, + "monday": { + "google": "Open 24 hours", + "apple": "12:00 AM-12:00 PM" + }, + "tuesday": { + "google": "Open 24 hours", + "apple": "12:00 AM-12:00 PM" + }, + "wednesday": { + "google": "Open 24 hours", + "apple": "12:00 AM-12:00 PM" + }, + "thursday": { + "google": "Open 24 hours", + "apple": "12:00 AM-12:00 PM" + }, + "friday": { + "google": "Open 24 hours", + "apple": "12:00 AM-12:00 PM" + } + } + } + }, + "primary_source": "google_business_profile", + "name": "Gilmore Heating, Air and Plumbing", + "address": "8636 Antelope North Rd Bldg D, Antelope, CA 95843", + "phone": "(530) 290-5551", + "website": "https://gilmoreair.com/service-areas/antelope/?utm_source=GBP-listing&utm_medium=organic&utm_campaign=antelope-ca", + "rating": 4.8, + "reviews": 3860, + "reviews_sample": [ + { + "text": "Gilmore repaired a leaky pipe under my bathroom wash basin. Before installing, I asked how much was it. They said $531. They did the work but had to come back the next day to use plumber's tape to fix", + "rating": 2 + }, + { + "text": "The work was good, but the pricing is outrageous. I wish I would've read the reviews prior to agreeing to the service. Make sure you shop around.", + "rating": 2 + }, + { + "text": "If you schedule an appointment with them, it will be a full day window, and there's no guarantee they'll show up until after that 8-5 window. If you call OR schedule online for a specific fix (in my c", + "rating": 1 + } + ], + "hours": { + "saturday": "Open 24 hours", + "sunday": "Open 24 hours", + "monday": "Open 24 hours", + "tuesday": "Open 24 hours", + "wednesday": "Open 24 hours", + "thursday": "Open 24 hours", + "friday": "Open 24 hours" + }, + "category": "HVAC contractor", + "price_level": null, + "photos_count": null, + "description": null, + "coordinates": { + "lat": 38.7186718, + "lon": -121.3265518 + }, + "closed_status": { + "permanently_closed": false, + "temporarily_closed": false + }, + "sources": { + "google_business_profile": { + "name": "Gilmore Heating, Air and Plumbing", + "address": "8636 Antelope North Rd Bldg D, Antelope, CA 95843", + "phone": "(530) 290-5551", + "website": "https://gilmoreair.com/service-areas/antelope/?utm_source=GBP-listing&utm_medium=organic&utm_campaign=antelope-ca", + "rating": 4.8, + "reviews": 3860, + "hours": { + "saturday": "Open 24 hours", + "sunday": "Open 24 hours", + "monday": "Open 24 hours", + "tuesday": "Open 24 hours", + "wednesday": "Open 24 hours", + "thursday": "Open 24 hours", + "friday": "Open 24 hours" + }, + "category": "HVAC contractor", + "price_level": null, + "description": null, + "photos_count": null, + "latitude": 38.7186718, + "longitude": -121.3265518, + "url": "https://www.google.com/maps/place/Gilmore+Heating,+Air+and+Plumbing/@38.7186718,-121.3265518,17z/data=!3m1!4b1!4m6!3m5!1s0x809b21e778a000f3:0x5e737079a3661740!8m2!3d38.7186718!4d-121.3265518!16s%2Fg%2F11nl7ls20_?entry=ttu&g_ep=EgoyMDI2MDgxMi4wIKXMDSoASAFQAw%3D%3D", + "permanently_closed": false, + "temporarily_closed": false + }, + "apple_maps": { + "address": "8636 Antelope North Rd, Antelope, CA 95843", + "hours": { + "sunday": "12:00 AM-12:00 PM", + "monday": "12:00 AM-12:00 PM", + "tuesday": "12:00 AM-12:00 PM", + "wednesday": "12:00 AM-12:00 PM", + "thursday": "12:00 AM-12:00 PM", + "friday": "12:00 AM-12:00 PM", + "saturday": "12:00 AM-12:00 PM" + }, + "rating": 2.5, + "reviews": 272, + "coordinates": { + "lat": 38.7186405, + "lon": -121.3270005 + }, + "name": "Gilmore Heating, Air And Plumbing", + "category": "Consumer Sector", + "phone": "+1 (916) 866-5596", + "website": "https://gilmoreair.com", + "reviews_sample": [ + { + "text": "Gilmore repaired a leaky pipe under my bathroom wash basin. Before installing, I asked how much was it. They said $531. They did the work but had to come back the next day to use plumber's tape to fix", + "rating": 2 + }, + { + "text": "The work was good, but the pricing is outrageous. I wish I would've read the reviews prior to agreeing to the service. Make sure you shop around.", + "rating": 2 + }, + { + "text": "If you schedule an appointment with them, it will be a full day window, and there's no guarantee they'll show up until after that 8-5 window. If you call OR schedule online for a specific fix (in my c", + "rating": 1 + } + ] + }, + "bing_places": null, + "website": null + } +} \ No newline at end of file diff --git a/docs/validation/2026-08-15-golden-hills-family-dental/REPORT-final.md b/docs/validation/2026-08-15-golden-hills-family-dental/REPORT-final.md index 35de824..cff620c 100644 --- a/docs/validation/2026-08-15-golden-hills-family-dental/REPORT-final.md +++ b/docs/validation/2026-08-15-golden-hills-family-dental/REPORT-final.md @@ -11,7 +11,7 @@ ## Executive Summary -We audited Golden Hills Family Dental across its primary online surfaces: Google Business Profile, Apple Maps, and the business website. The business shows 5.0 ★ across 283 Google reviews. +We audited Golden Hills Family Dental across its primary online surfaces: Google Business Profile, Apple Maps and Website. The business shows 5.0 ★ across 283 Google reviews. **2 material integrity issues were identified:** 1. Phone numbers are inconsistent across directories. @@ -25,6 +25,7 @@ We audited Golden Hills Family Dental across its primary online surfaces: Google |---------|--------|--------|---------| | Google Business Profile | ✓ | 5.0 ★ | 283 | | Apple Maps | ✓ | 4.7 ★ | 41 | +| Bing Places | ✗ | capture blocked (bot-wall) — excluded, not verified | — | | Website | ✓ | — | — | --- @@ -57,4 +58,12 @@ Add LocalBusiness JSON-LD (Dentist/Plumber/Contractor type) with name, address, --- -*Prepared under the VeriPath beta audit process v1.0. Findings reflect reviewed engine output; format-level discrepancies were normalized before inclusion.* +## Data Limitations + +This audit ran without authenticated access to the business's listings. Known ceilings: + +- **Review text and dates** were not captured; ratings and counts only. +- **Bing Places** is bot-walled for headless capture in most runs; where it shows ✗ above, that surface is excluded from this audit, not verified. +- This is a **point-in-time snapshot**; directory data changes after the audit date. + +*Prepared under the VeriPath beta audit process v1.1. Findings reflect reviewed engine output; format-level discrepancies were normalized before inclusion.* diff --git a/docs/validation/2026-08-15-golden-hills-family-dental/VALIDATION.md b/docs/validation/2026-08-15-golden-hills-family-dental/VALIDATION.md index c5d3b8d..0fbea89 100644 --- a/docs/validation/2026-08-15-golden-hills-family-dental/VALIDATION.md +++ b/docs/validation/2026-08-15-golden-hills-family-dental/VALIDATION.md @@ -1,11 +1,11 @@ # Validation Run: Golden Hills Family Dental -**Date:** 2026-08-15 +**Date:** 15 August 2026 **Business:** Golden Hills Family Dental **Rating:** 5.0 ★ | **Reviews:** 283 **Engine findings:** 10 | **Material (in report):** 2 | **Rejected FP/low-signal:** 5 | **Needs verification:** 0 -## Reviewer dispositions (locked process v1.0 §3) +## Reviewer dispositions (locked process v1.1 §3) ### Material (survive normalization → in REPORT-final.md) @@ -32,9 +32,19 @@ - Price level not set on GBP or Apple Maps - No Open Graph tags on website -## Pre-delivery consistency gate (v1.0 §6) +## Pre-delivery consistency gate (v1.1 §6) - Count match: executive summary states 2; Findings section contains 2 ✅ - Evidence present: every cited value exists in findings.json / raw capture ✅ - Identity match: name, location, and date match intake record ✅ -*Generated by normalization pass (review_pass rules). Reviewer: dispositions above are the standing review for this run; re-run before any client use if the raw capture is re-fetched.* +*Generated by report_generate.py (locked normalization rules). Gate result appended by report_gate.py before delivery.* + +## GATE RESULT + +**PASS** — report_gate.py, 2026-08-15T16:27:11 + +- Count: 2 stated / 2 material after review — match +- Identity: report title + date match raw capture ('Golden Hills Family Dental', 15 August 2026) — match +- Surface disclosure: all captured/failed surfaces rendered correctly — match +- Material support: every material finding re-derived from raw capture — hold +- Needs-verification open: 0 diff --git a/docs/validation/2026-08-15-gordon-law-firm/REPORT-final.md b/docs/validation/2026-08-15-gordon-law-firm/REPORT-final.md index ea996f7..0feca87 100644 --- a/docs/validation/2026-08-15-gordon-law-firm/REPORT-final.md +++ b/docs/validation/2026-08-15-gordon-law-firm/REPORT-final.md @@ -11,7 +11,7 @@ ## Executive Summary -We audited Gordon Law across its primary online surfaces: Google Business Profile, Apple Maps, and the business website. The business shows 4.9 ★ across 45 Google reviews. +We audited Gordon Law across its primary online surfaces: Google Business Profile and Apple Maps. The business shows 4.9 ★ across 45 Google reviews. **4 material integrity issues were identified:** 1. Phone numbers are inconsistent across directories. @@ -27,7 +27,8 @@ We audited Gordon Law across its primary online surfaces: Google Business Profil |---------|--------|--------|---------| | Google Business Profile | ✓ | 4.9 ★ | 45 | | Apple Maps | ✓ | No rating | No reviews | -| Website | ✗ | Not found / unreachable | — | +| Bing Places | ✗ | capture blocked (bot-wall) — excluded, not verified | — | +| Website | ✗ | not found / unreachable at audit time | — | > **Limitation:** The website could not be reached or located at audit time. A blocked or missing website is itself a customer-path risk: visitors arriving from search cannot engage the business. Reachability should be re-verified; if the block persists, treat it as a material finding in the next run. @@ -77,7 +78,7 @@ Stand up (or link on GBP) a business website with service pages, contact details ### Finding 4: No business description found on any surface **Observation:** -{"surfaces_checked": ["google", "bing", "website"]} +no description on any checked surface — weakens local-pack snippet **Impact:** An empty description leaves the local-pack snippet to algorithmic default and forfeits the business's own service language in search results. @@ -87,4 +88,16 @@ Write a 2-3 sentence GBP description covering services, service area, and differ --- -*Prepared under the VeriPath beta audit process v1.0. Findings reflect reviewed engine output; format-level discrepancies were normalized before inclusion.* +## Verification Pending + +- **Address inconsistent across 1 surface(s)** — unit designators differ: google='FL 2' vs apple='STEE 210' — verify on-site which is correct + +## Data Limitations + +This audit ran without authenticated access to the business's listings. Known ceilings: + +- **Review text and dates** were not captured; ratings and counts only. +- **Bing Places** is bot-walled for headless capture in most runs; where it shows ✗ above, that surface is excluded from this audit, not verified. +- This is a **point-in-time snapshot**; directory data changes after the audit date. + +*Prepared under the VeriPath beta audit process v1.1. Findings reflect reviewed engine output; format-level discrepancies were normalized before inclusion.* diff --git a/docs/validation/2026-08-15-gordon-law-firm/VALIDATION.md b/docs/validation/2026-08-15-gordon-law-firm/VALIDATION.md index d47e98c..d8cce9f 100644 --- a/docs/validation/2026-08-15-gordon-law-firm/VALIDATION.md +++ b/docs/validation/2026-08-15-gordon-law-firm/VALIDATION.md @@ -1,11 +1,11 @@ # Validation Run: Gordon Law -**Date:** 2026-08-15 +**Date:** 15 August 2026 **Business:** Gordon Law **Rating:** 4.9 ★ | **Reviews:** 45 -**Engine findings:** 8 | **Material (in report):** 4 | **Rejected FP/low-signal:** 3 | **Needs verification:** 0 +**Engine findings:** 8 | **Material (in report):** 4 | **Rejected FP/low-signal:** 2 | **Needs verification:** 1 -## Reviewer dispositions (locked process v1.0 §3) +## Reviewer dispositions (locked process v1.1 §3) ### Material (survive normalization → in REPORT-final.md) @@ -20,11 +20,11 @@ ### Needs verification (excluded from report until confirmed) -- none +- **immediate** — Address inconsistent across 1 surface(s) + `unit designators differ: google='FL 2' vs apple='STEE 210' — verify on-site which is correct` ### Rejected (format false-positives / low-signal / tooling gaps) -- ~~Address inconsistent across 1 surface(s)~~ — same street/city/zip; unit designator format only - ~~Cannot determine review recency — no dates available in samples~~ — tooling gap: scraper captures no review dates (documented limitation) - ~~Category varies across surfaces: consumer sector, personal injury attorney~~ — low-signal: per-platform taxonomy (Apple 'consumer sector' is its default) @@ -32,9 +32,19 @@ - Price level not set on GBP or Apple Maps -## Pre-delivery consistency gate (v1.0 §6) +## Pre-delivery consistency gate (v1.1 §6) - Count match: executive summary states 4; Findings section contains 4 ✅ - Evidence present: every cited value exists in findings.json / raw capture ✅ - Identity match: name, location, and date match intake record ✅ -*Generated by normalization pass (review_pass rules). Reviewer: dispositions above are the standing review for this run; re-run before any client use if the raw capture is re-fetched.* +*Generated by report_generate.py (locked normalization rules). Gate result appended by report_gate.py before delivery.* + +## GATE RESULT + +**PASS** — report_gate.py, 2026-08-15T16:27:11 + +- Count: 4 stated / 4 material after review — match +- Identity: report title + date match raw capture ('Gordon Law', 15 August 2026) — match +- Surface disclosure: all captured/failed surfaces rendered correctly — match +- Material support: every material finding re-derived from raw capture — hold +- Needs-verification open: 1 diff --git a/docs/validation/2026-08-15-scottys-heating-air/REPORT-final.md b/docs/validation/2026-08-15-scottys-heating-air/REPORT-final.md index b5da717..eb8a050 100644 --- a/docs/validation/2026-08-15-scottys-heating-air/REPORT-final.md +++ b/docs/validation/2026-08-15-scottys-heating-air/REPORT-final.md @@ -11,7 +11,7 @@ ## Executive Summary -We audited Scotty's Heating & Air across its primary online surfaces: Google Business Profile, Apple Maps, and the business website. The business shows 4.8 ★ across 180 Google reviews. +We audited Scotty's Heating & Air across its primary online surfaces: Google Business Profile, Apple Maps and Website. The business shows 4.8 ★ across 180 Google reviews. **3 material integrity issues were identified:** 1. Phone numbers are inconsistent across directories. @@ -26,6 +26,7 @@ We audited Scotty's Heating & Air across its primary online surfaces: Google Bus |---------|--------|--------|---------| | Google Business Profile | ✓ | 4.8 ★ | 180 | | Apple Maps | ✓ | 4.1 ★ | 37 | +| Bing Places | ✗ | capture blocked (bot-wall) — excluded, not verified | — | | Website | ✓ | — | — | --- @@ -75,4 +76,12 @@ Verify which rating is current; the stale surface usually reflects an unmanaged - **Hours mismatch on 5 day(s) — GBP is authoritative** — 5 day(s) affected by '1800' parse artifact: monday: 8 AM-4:30 PM vs 8:00 AM-4:1800 PM; tuesday: 8 AM-4:30 PM vs 8:00 AM-4:1800 PM; wednesday: 8 AM-4:30 PM vs 8:00 AM-4:1800 PM — verify against live Apple Maps; excluded from report until confirmed -*Prepared under the VeriPath beta audit process v1.0. Findings reflect reviewed engine output; format-level discrepancies were normalized before inclusion.* +## Data Limitations + +This audit ran without authenticated access to the business's listings. Known ceilings: + +- **Review text and dates** were not captured; ratings and counts only. +- **Bing Places** is bot-walled for headless capture in most runs; where it shows ✗ above, that surface is excluded from this audit, not verified. +- This is a **point-in-time snapshot**; directory data changes after the audit date. + +*Prepared under the VeriPath beta audit process v1.1. Findings reflect reviewed engine output; format-level discrepancies were normalized before inclusion.* diff --git a/docs/validation/2026-08-15-scottys-heating-air/VALIDATION.md b/docs/validation/2026-08-15-scottys-heating-air/VALIDATION.md index 535d23e..b775e6e 100644 --- a/docs/validation/2026-08-15-scottys-heating-air/VALIDATION.md +++ b/docs/validation/2026-08-15-scottys-heating-air/VALIDATION.md @@ -1,11 +1,11 @@ # Validation Run: Scotty's Heating & Air -**Date:** 2026-08-15 +**Date:** 15 August 2026 **Business:** Scotty's Heating & Air **Rating:** 4.8 ★ | **Reviews:** 180 **Engine findings:** 10 | **Material (in report):** 3 | **Rejected FP/low-signal:** 4 | **Needs verification:** 1 -## Reviewer dispositions (locked process v1.0 §3) +## Reviewer dispositions (locked process v1.1 §3) ### Material (survive normalization → in REPORT-final.md) @@ -33,9 +33,19 @@ - Only 0% of sample reviews mention specific services or practitioners - Price level not set on GBP or Apple Maps -## Pre-delivery consistency gate (v1.0 §6) +## Pre-delivery consistency gate (v1.1 §6) - Count match: executive summary states 3; Findings section contains 3 ✅ - Evidence present: every cited value exists in findings.json / raw capture ✅ - Identity match: name, location, and date match intake record ✅ -*Generated by normalization pass (review_pass rules). Reviewer: dispositions above are the standing review for this run; re-run before any client use if the raw capture is re-fetched.* +*Generated by report_generate.py (locked normalization rules). Gate result appended by report_gate.py before delivery.* + +## GATE RESULT + +**PASS** — report_gate.py, 2026-08-15T16:27:11 + +- Count: 3 stated / 3 material after review — match +- Identity: report title + date match raw capture ("Scotty's Heating & Air", 15 August 2026) — match +- Surface disclosure: all captured/failed surfaces rendered correctly — match +- Material support: every material finding re-derived from raw capture — hold +- Needs-verification open: 1 diff --git a/implementation/auditing/audit_diff.py b/implementation/auditing/audit_diff.py new file mode 100644 index 0000000..550e1e6 --- /dev/null +++ b/implementation/auditing/audit_diff.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Locked process v1.1: temporal delta — compare two raw captures, emit DELTA.md. + +Usage: + audit_diff.py BEFORE_RUN_DIR AFTER_RUN_DIR + +Compares the LATEST capture in each run dir across the fields the audit +scores (name, phone, address, hours per day, rating, reviews, website, +category, description, schema presence) and writes DELTA.md into the AFTER +dir. This is the proof-of-fix artifact: after the client applies the +recommendations, re-run the pipeline and the delta is the invoice evidence. + +Exit 0 always (a diff is a result, not an error); DELTA.md lists every +change and confirms unchanged fields. +""" +import json, os, sys, glob + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import report_generate as rg # latest_capture + normalization + +DAYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] + +def load(d): + if os.path.isfile(d): + f = d + else: + f = rg.latest_capture(d) + raw = json.load(open(f)) + src = raw.get("sources", {}) + g = src.get("google_business_profile") or {} + a = src.get("apple_maps") or {} + w = src.get("website") if isinstance(src.get("website"), dict) else {} + return raw, g, a, w + +def row(label, before, after, fmt=str): + b, n = fmt(before), fmt(after) + if b == n: + return None, (b, b) + return (label, b, n), (b, n) + +def schema_present(w): + return bool(w.get("schema")) if isinstance(w, dict) else False + +def compare(before_dir, after_dir): + raw_b, g_b, a_b, w_b = load(before_dir) + raw_a, g_a, a_a, w_a = load(after_dir) + changes, same = [], [] + + def add(label, b, n, fmt=str): + r, vals = row(label, b, n, fmt) + (changes if r else same).append(r or (label, vals[0])) + + add("Name (GBP)", g_b.get("name"), g_a.get("name")) + add("Phone (GBP)", rg.phone_core(g_b.get("phone")), rg.phone_core(g_a.get("phone"))) + add("Phone (Apple)", rg.phone_core(a_b.get("phone")), rg.phone_core(a_a.get("phone"))) + add("Address (GBP)", rg.addr_core(g_b.get("address")), rg.addr_core(g_a.get("address"))) + add("Address (Apple)", rg.addr_core(a_b.get("address")), rg.addr_core(a_a.get("address"))) + add("Website (GBP)", g_b.get("website"), g_a.get("website")) + add("Rating (GBP)", g_b.get("rating"), g_a.get("rating")) + add("Reviews (GBP)", g_b.get("reviews"), g_a.get("reviews")) + add("Category (GBP)", g_b.get("category"), g_a.get("category")) + add("Description set (GBP)", bool(g_b.get("description")), bool(g_a.get("description"))) + add("Schema (website)", schema_present(w_b), schema_present(w_a)) + + for day in DAYS: + gh_b, gh_a = (g_b.get("hours") or {}).get(day), (g_a.get("hours") or {}).get(day) + if gh_b != gh_a: + changes.append((f"Hours {day} (GBP)", gh_b or "—", gh_a or "—")) + ah_b, ah_a = (a_b.get("hours") or {}).get(day), (a_a.get("hours") or {}).get(day) + if ah_b != ah_a: + changes.append((f"Hours {day} (Apple)", ah_b or "—", ah_a or "—")) + + return changes, same, raw_b, raw_a + +def main(): + if len(sys.argv) != 3: + sys.exit("usage: audit_diff.py BEFORE_RUN_DIR AFTER_RUN_DIR") + before_dir, after_dir = sys.argv[1], sys.argv[2] + changes, same, raw_b, raw_a = compare(before_dir, after_dir) + L = [f"# Delta: {raw_a.get('name', '?')}", "", + f"**Before:** {rg.run_date(raw_b)} (`{os.path.basename(before_dir)}`) ", + f"**After:** {rg.run_date(raw_a)} (`{os.path.basename(after_dir)}`) ", + "", f"**{len(changes)} field(s) changed** across the audited surfaces.", ""] + if changes: + L += ["## Changed", "", "| Field | Before | After |", "|-------|--------|-------|"] + for label, b, n in changes: + L.append(f"| {label} | {b} | {n} |") + L.append("") + else: + L += ["No changes detected between the two captures.", ""] + L += ["## Unchanged", ""] + L += [f"- {label}: {v}" for label, v in same] + L += ["", "*Generated by audit_diff.py (locked process v1.1). Compare only — no judgment.*", ""] + out = os.path.join(after_dir, "DELTA.md") if os.path.isdir(after_dir) else \ + os.path.join(os.path.dirname(os.path.abspath(after_dir)), "DELTA.md") + open(out, "w").write("\n".join(L)) + print(f"{os.path.basename(after_dir)}: {len(changes)} change(s) -> {out}") + +if __name__ == "__main__": + main() diff --git a/implementation/auditing/audit_pipeline.sh b/implementation/auditing/audit_pipeline.sh index c76de74..2b02246 100755 --- a/implementation/auditing/audit_pipeline.sh +++ b/implementation/auditing/audit_pipeline.sh @@ -9,6 +9,11 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" BUSINESS="${1:?Usage: ./audit_pipeline.sh \"Business Name\" \"City, ST\" [output-dir]}" LOCATION="${2:?Usage: ./audit_pipeline.sh \"Business Name\" \"City, ST\" [output-dir]}" OUTPUT_DIR="${3:-}" +# cwd-proof: resolve to absolute once, so relative output dirs can't break capture copy +if [ -n "$OUTPUT_DIR" ]; then + mkdir -p "$OUTPUT_DIR" + OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)" +fi # Temporary working dir for this run WORK_DIR=$(mktemp -d /tmp/veripath.XXXXXX) @@ -30,12 +35,30 @@ echo "[OK] Extraction complete: $SCRAPER_OUT" # Step 2: Audit engine if [ -n "$OUTPUT_DIR" ]; then mkdir -p "$OUTPUT_DIR" - # Required artifact set (locked beta process v1.0): raw capture + findings in the run folder - cp "$SCRAPER_OUT" "$OUTPUT_DIR/" + # Required artifact set (locked beta process v1.1): raw capture + findings in the run folder + # Collision-safe: a re-run never overwrites the prior capture (temporal baseline, v1.1 §5) + DEST="$OUTPUT_DIR/$(basename "$SCRAPER_OUT")" + if [ -e "$DEST" ]; then + BASE="$(basename "${DEST%.*}")"; EXT="${DEST##*.}" + DEST="$OUTPUT_DIR/${BASE}__$(date +%Y%m%d-%H%M%S).$EXT" + echo "[WARN] prior capture present — writing $DEST (baseline preserved)" + fi + cp "$SCRAPER_OUT" "$DEST" ENGINE_ARGS+=("--output-dir" "$OUTPUT_DIR") echo "[$(date +%T)] Auditing (output: $OUTPUT_DIR)" uv run python3 "$SCRIPT_DIR/audit_engine.py" "$SCRAPER_OUT" "${ENGINE_ARGS[@]}" - echo "[DONE] Findings + raw capture written to $OUTPUT_DIR/" + + # Step 3: Report + validation (locked process v1.1 §3) + echo "[$(date +%T)] Generating report + validation" + uv run python3 "$SCRIPT_DIR/report_generate.py" "$OUTPUT_DIR" + + # Step 4: Pre-delivery consistency gate (v1.1 §6) — FAIL blocks delivery + echo "[$(date +%T)] Consistency gate" + if ! uv run python3 "$SCRIPT_DIR/report_gate.py" "$OUTPUT_DIR"; then + echo "[GATE FAIL] $OUTPUT_DIR — do NOT deliver; see GATE RESULT in VALIDATION.md" >&2 + exit 1 + fi + echo "[DONE] Findings + raw capture + report + gate written to $OUTPUT_DIR/" else echo "[$(date +%T)] Auditing (stdout)" uv run python3 "$SCRIPT_DIR/audit_engine.py" "$SCRAPER_OUT" "${ENGINE_ARGS[@]}" diff --git a/implementation/auditing/report_gate.py b/implementation/auditing/report_gate.py new file mode 100644 index 0000000..d29b0e6 --- /dev/null +++ b/implementation/auditing/report_gate.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Locked process v1.1 §6: pre-delivery consistency gate (scripted). + +Usage: + report_gate.py RUN_DIR [--strict] + +Checks (all must pass): + 1. Count match — executive-summary count == "### Finding N" count in REPORT-final.md + 2. Evidence present — every material finding's evidence exists in findings.json + with a non-empty payload + 3. Identity match — report business name == raw capture name; report date == capture date + 4. Surface disclosure — every surface with audit.surfaces_checked == false + renders as ✗ in the Surfaces Reviewed table; no surface with a false flag + renders as ✓ + 5. Material support — re-derivation: for each material phone/address/hours finding, + the claim is re-computed from the RAW CAPTURE (not the engine's evidence) and + must still hold. Catches report/engine drift, not judgment calls. + +Exit 0 = PASS, 1 = FAIL (failures printed). Appends a GATE RESULT block to +VALIDATION.md (replaces the prior block if re-run on the same report). +--strict additionally fails when needs-verification items exist (pre-ship). +""" +import datetime, glob, json, os, re, sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import report_generate as rg # reuse normalization + loaders — single source of truth + +def phone_core(s): + d = re.sub(r"\D", "", s or "") + if len(d) == 11 and d.startswith("1"): + d = d[1:] + if not d or set(d) == {"0"}: + return "" + return d + +def addr_core(s): + if not s: + return "" + a = s.lower() + a = re.sub(r"\b(suite|ste|st|unit|fl|floor)\b\.?\s*\w*", " ", a) + a = re.sub(r"#\s*\w*", " ", a) + a = re.sub(r"[^\w\s]", " ", a) + return re.sub(r"\s+", " ", a).strip() + +def day_eq(g, a): + tg, ta = rg.tmin(g), rg.tmin(a) + if "PARSE1800" in (tg, ta): + return "parse" + return "eq" if tg == ta else "diff" + +def url_norm(u): + if not u: + return "" + u = re.sub(r"[?#].*$", "", u) + u = re.sub(r"^https?://", "", u) + u = re.sub(r"^www\.", "", u) + return u.rstrip("/").lower() + +def latest_capture(run_dir): + return rg.latest_capture(run_dir) + +NUM_WORDS = {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5, "Six": 6, "Seven": 7, "Eight": 8, "Nine": 9} + +# findings whose whole claim IS an absence — the engine legitimately emits no evidence for them; +# gate re-derives them from raw capture instead (check 5) +ABSENCE_TITLES = ( + "No website found", "unreachable", "No business description", "No JSON-LD", "no LocalBusiness", + "Price level", "Open Graph", "UTM parameters", +) + +def check(run_dir, strict=False): + fails = [] + raw = json.load(open(latest_capture(run_dir))) + fj = json.load(open(os.path.join(run_dir, "findings.json"))) + report = open(os.path.join(run_dir, "REPORT-final.md")).read() + name = raw.get("name") or fj.get("business") + + # 1. count match + n_section = len(re.findall(r"^### Finding \d+:", report, re.M)) + m = re.search(r"\b(\w+) material integrity issue", report) + if m: + w = m.group(1) + n_stated = NUM_WORDS.get(w, w) + n_stated = int(n_stated) if str(n_stated).isdigit() else 0 + else: + n_stated = 0 + if not (n_section == n_stated): + fails.append(f"count mismatch: summary says {n_stated}, findings section has {n_section}") + + # 2. evidence present — presence findings must carry engine evidence; absence findings + # (no-website / no-description / no-schema / price / OG) are verified from raw in check 5 + mat, fp, nv, enh = rg.classify(fj, raw) + ev_by_title = {f["title"]: f.get("evidence") for f in fj["findings"]} + for sev, t, _why in mat: + if any(t.startswith(a) or a in t for a in ABSENCE_TITLES): + continue + ev = ev_by_title.get(t) + if ev in (None, "", {}, []): + fails.append(f"material finding with empty evidence: {t}") + if strict and nv: + fails.append(f"strict mode: {len(nv)} needs-verification item(s) open") + + # 3. identity match + if not report.startswith(f"# {name}\n"): + fails.append(f"identity: report title != capture name ({name!r})") + d = rg.run_date(raw) + if d != "date unknown" and d not in report: + fails.append(f"identity: capture date {d!r} not in report") + + # 4. surface disclosure + checked = raw.get("audit", {}).get("surfaces_checked", {}) + table = report.split("## Surfaces Reviewed") + if len(table) < 2: + fails.append("surfaces: no Surfaces Reviewed section") + else: + tbl = table[1].split("##")[0] + for key, label in rg.SURFACES: + rows = [l for l in tbl.splitlines() if l.startswith(f"| {label} |")] + if not rows: + if checked.get(key): + fails.append(f"surfaces: row missing for captured surface {label}") + continue + ok = rows[0].split("|")[2].strip().startswith("✓") + if checked.get(key) is False and ok: + fails.append(f"surfaces: {label} failed capture but shows ✓") + if checked.get(key) is True and not ok: + fails.append(f"surfaces: {label} captured but shows ✗") + + # 5. material support — re-derive material claims from RAW CAPTURE + src = raw.get("sources", {}) + g = src.get("google_business_profile") or {} + a = src.get("apple_maps") or {} + w = src.get("website") if isinstance(src.get("website"), dict) else {} + for sev, t, why in mat: + if t.startswith("Phone"): + cores = {phone_core(x) for x in (g.get("phone"), a.get("phone"), w.get("phone_on_page")) if phone_core(x)} + if len(cores) <= 1: + fails.append(f"support: phone claim not re-derived from raw (cores={cores})") + elif t.startswith("Address") and "unit designators" not in why: + if addr_core(g.get("address")) == addr_core(a.get("address")): + fails.append(f"support: address claim not re-derived from raw (cores equal)") + elif t.startswith("Hours"): + days = [] + for day in ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"): + gh = (g.get("hours") or {}).get(day) + ah = (a.get("hours") or {}).get(day) + if gh and ah and day_eq(gh, ah) == "diff": + days.append(day) + if not days: + fails.append(f"support: hours claim not re-derived from raw (no differing day)") + elif "UTM" in t: + gu, au = g.get("website"), a.get("website") + if not (( "?" in (gu or "")) != ("?" in (au or ""))): + fails.append(f"support: UTM claim not re-derived from raw") + elif t.startswith("No website") or "unreachable" in t: + if not (g.get("website") or "").strip(): + fails.append("support: unreachable-website claim not re-derived (GBP has no website)") + elif t.startswith("No business description"): + if any((s.get("description") or "").strip() for s in (g, a) if isinstance(s, dict)): + fails.append("support: no-description claim not re-derived (a surface has a description)") + + return fails, (n_stated, len(mat), len(nv), name, d) + +def append_gate(run_dir, ok, fails, stats): + p = os.path.join(run_dir, "VALIDATION.md") + v = open(p).read() if os.path.exists(p) else "" + v = re.sub(r"\n## GATE RESULT.*", "", v, flags=re.S) + n_stated, n_mat, n_nv, name, d = stats + block = "\n## GATE RESULT\n\n" + block += f"**{('PASS' if ok else 'FAIL')}** — report_gate.py, {datetime.datetime.now().isoformat(timespec='seconds')}\n\n" + block += (f"- Count: {n_stated} stated / {n_mat} material after review — " + + ("match" if n_stated == n_mat else f"MISMATCH ({n_stated} vs {n_mat})") + "\n" + f"- Identity: report title + date match raw capture ({name!r}, {d}) — " + + ("match" if not any(f.startswith("identity") for f in fails) else "FAIL") + "\n" + f"- Surface disclosure: all captured/failed surfaces rendered correctly — " + + ("match" if not any(f.startswith("surfaces") for f in fails) else "FAIL") + "\n" + f"- Material support: every material finding re-derived from raw capture — " + + ("hold" if not any(f.startswith("support") for f in fails) else "FAIL") + "\n" + f"- Needs-verification open: {n_nv}\n") + if fails: + block += "\n**Failures:**\n" + "".join(f"- {f}\n" for f in fails) + open(p, "w").write(v + block) + +def main(): + args = [a for a in sys.argv[1:]] + strict = "--strict" in args + dirs = [a for a in args if a != "--strict"] + if not dirs: + sys.exit("usage: report_gate.py RUN_DIR [--strict]") + rc = 0 + for run_dir in dirs: + ok, fails = True, [] + try: + fails, stats = check(run_dir, strict=strict) + except Exception as e: + fails = [f"gate crashed: {e}"] + stats = (0, 0, 0, "?", "?") + ok = not fails + if not ok: + rc = 1 + append_gate(run_dir, ok, fails, stats) + print(f"{os.path.basename(run_dir):42} {'PASS' if ok else 'FAIL'}") + for f in fails: + print(f" - {f}") + sys.exit(rc) + +if __name__ == "__main__": + main() diff --git a/implementation/auditing/report_generate.py b/implementation/auditing/report_generate.py new file mode 100644 index 0000000..1bb8acb --- /dev/null +++ b/implementation/auditing/report_generate.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +"""Locked process v1.1 step 3: report + validation builder (promoted from the tmp one-off). + +Usage: + report_generate.py RUN_DIR # one run folder + report_generate.py --all VALIDATION_DIR + +Reads engine findings + latest raw capture per run, normalizes format +false-positives, classifies material vs FP vs needs-verification, emits +REPORT-final.md (client) + VALIDATION.md (process). + +v1.1 changes vs the tmp script: + - no hardcoded VS/DATE/skips: run dir is an argument, date from audit.timestamp + - surfaces table driven by audit.surfaces_checked (per-surface OK/FAIL + reason) + - Data Limitations section in every report (headless ceilings, locked process 4.2) + - unit-designator divergence -> needs-verification generically (no run-name special case) +Deterministic rules + explicit normalization; no LLM in this path. +""" +import argparse, datetime, glob, json, os, re + +SURFACES = [ + ("google_business_profile", "Google Business Profile"), + ("apple_maps", "Apple Maps"), + ("bing_places", "Bing Places"), + ("website", "Website"), +] +FAIL_REASON = { + "google_business_profile": "capture failed — excluded from this audit", + "apple_maps": "capture failed — excluded from this audit", + "bing_places": "capture blocked (bot-wall) — excluded, not verified", + "website": "not found / unreachable at audit time", +} + +# ---------- normalization ---------- +def phone_core(s): + d = re.sub(r"\D", "", s or "") + if len(d) == 11 and d.startswith("1"): + d = d[1:] + if not d or set(d) == {"0"}: # "0000000000" scrape artifact + return "" + return d + +def addr_core(s): + if not s: + return "" + a = s.lower() + a = re.sub(r"\b(suite|ste|st|unit|fl|floor)\b\.?\s*\w*", " ", a) + a = re.sub(r"#\s*\w*", " ", a) + a = re.sub(r"[^\w\s]", " ", a) + a = re.sub(r"\s+", " ", a).strip() + return a + +def suite_raw(s): + """extract the unit designator as written (for needs-verification note)""" + m = re.search(r"\b(suite|ste|st|unit|fl|floor)\b\.?\s*([\w-]+)?", s or "", re.I) + if not m: + return None + return (m.group(1).upper().replace("ST", "STE") + " " + (m.group(2) or "")).strip() or None + +def tmin(t): + """'8 AM' / '8:00 AM' / 'Open 24 hours' / 'Closed' / '4:1800 PM' -> minutes or tag""" + t = (t or "").strip() + if re.search(r"1800", t): + return "PARSE1800" + if "24 hours" in t.lower(): + return "24H" + if "closed" in t.lower(): + return "CLOSED" + m = re.match(r"(\d{1,2})(?::(\d{2}))?\s*([ap])m", t, re.I) + if not m: + return "OTHER:" + t + h, mi, ap = int(m.group(1)), int(m.group(2) or 0), m.group(3).lower() + if h == 12: h = 0 + if ap == "p": h += 12 + return h * 60 + mi + +def day_eq(g, a): + """compare one day's google vs apple hours -> 'eq' | 'diff' | 'parse'""" + tg, ta = tmin(g), tmin(a) + if "PARSE1800" in (tg, ta): + return "parse" + return "eq" if tg == ta else "diff" + +def url_norm(u): + if not u: + return "" + u = re.sub(r"[?#].*$", "", u) # strip UTM/query + u = re.sub(r"^https?://", "", u) + u = re.sub(r"^www\.", "", u) + return u.rstrip("/").lower() + +# ---------- run loading ---------- +def latest_capture(run_dir): + fs = glob.glob(os.path.join(run_dir, "*_multi_surface_*.json")) + if not fs: + raise SystemExit(f"no raw capture in {run_dir}") + def ts(p): + try: + return json.load(open(p)).get("audit", {}).get("timestamp", "") + except Exception: + return "" + return max(fs, key=lambda p: (ts(p), os.path.getmtime(p))) + +def load_run(run_dir): + raw = json.load(open(latest_capture(run_dir))) + fj = json.load(open(os.path.join(run_dir, "findings.json"))) + return raw, fj + +def run_date(raw): + try: + return datetime.datetime.fromisoformat(raw["audit"]["timestamp"]).strftime("%d %B %Y") + except Exception: + return "date unknown" + +# ---------- per-run classification ---------- +def classify(fj, raw): + src = raw.get("sources", {}) + g = src.get("google_business_profile") or {} + a = src.get("apple_maps") or {} + w = src.get("website") + mat, fp, nv, enh = [], [], [], [] + + for f in fj["findings"]: + t, ev, sev = f["title"], f.get("evidence"), f["severity"] + + if t.startswith("Phone number inconsistent"): + cores = {phone_core(x) for x in (ev.get("google"), ev.get("apple"), ev.get("website")) if phone_core(x)} + if len(cores) <= 1: + fp.append((sev, t, "identical after digit/+1 normalization")) + else: + mat.append((sev, t, f"distinct numbers: {', '.join(sorted(cores))}")) + elif t.startswith("Address inconsistent"): + cg, ca = addr_core(ev.get("google")), addr_core(ev.get("apple")) + if not cg or not ca or cg == ca: + if cg and ca and cg == ca: + sg, sa = suite_raw(ev.get("google")), suite_raw(ev.get("apple")) + if sg and sa and sg != sa: + # same street core but different unit designators — genuinely ambiguous + nv.append((sev, t, f"unit designators differ: google='{sg}' vs apple='{sa}' — verify on-site which is correct")) + else: + fp.append((sev, t, "same street/city/zip; suite format only (# vs Ste vs Unit)")) + else: + fp.append((sev, t, "missing data on one surface")) + else: + mat.append((sev, t, f"google='{ev.get('google')}' vs apple='{ev.get('apple')}'")) + elif t.startswith("Hours mismatch"): + days = ev or [] + diffs, parses = [], [] + for d in days: + r = day_eq(d.get("google"), d.get("apple_maps")) + if r == "diff": diffs.append(d) + elif r == "parse": parses.append(d) + if diffs: + mat.append((sev, t, f"{len(diffs)} day(s) genuinely differ: " + + "; ".join(f"{d['day']}: {d['google']} vs {d['apple_maps']}" for d in diffs[:4]))) + elif parses: + nv.append((sev, t, f"{len(parses)} day(s) affected by '1800' parse artifact: " + + "; ".join(f"{d['day']}: {d['google']} vs {d['apple_maps']}" for d in parses[:3]) + + " — verify against live Apple Maps; excluded from report until confirmed")) + else: + fp.append((sev, t, "format only: '8 AM' vs '8:00 AM' — identical after parse")) + elif t.startswith("Website URL inconsistent"): + gu, au = ev.get("google"), ev.get("apple") + if url_norm(gu) == url_norm(au): + if "?" in (gu or "") or "?" in (au or ""): + mat.append(("medium", "Website URL carries UTM parameters on one listing", + f"google='{gu}' vs apple='{au}' — same domain, attribution/SEO split risk (Gilmore precedent: material)")) + else: + fp.append((sev, t, "same domain; http/https or www only")) + else: + mat.append((sev, t, f"google='{gu}' vs apple='{au}' — different domains")) + elif "below 4.0" in t: + mat.append((sev, t, f"average {ev.get('average')} (google {ev.get('by_source',{}).get('google')} / apple {ev.get('by_source',{}).get('apple')})")) + elif t.startswith("Rating varies"): + mat.append((sev, t, f"google {ev.get('google')} vs apple {ev.get('apple')}")) + elif t.startswith("No website found"): + gw = (g.get("website") or "").strip() + if gw: + mat.append((sev, "Website listed on GBP but unreachable at audit time", + f"GBP website='{gw}' — surface blocked; documented limitation per process §4")) + else: + mat.append((sev, t, "no website listed on Google Business Profile or found on other surfaces")) + elif t.startswith("No business description"): + mat.append(("medium", t, "no description on any checked surface — weakens local-pack snippet")) + elif "JSON-LD" in t and "no LocalBusiness" in t: + mat.append(("medium", "JSON-LD present but no LocalBusiness type", + f"schema at {ev.get('url')} lacks LocalBusiness/Dentist/Plumber type — AI/structured data can't classify the business")) + elif t.startswith("No JSON-LD"): + mat.append((sev, t, f"no structured data at {ev.get('url')}")) + elif t.startswith("Review count varies"): + fp.append((sev, t, "low-signal: platforms count reviews differently; not an integrity issue")) + elif "review recency" in t.lower(): + fp.append((sev, t, "tooling gap: scraper captures no review dates (documented limitation)")) + elif t.startswith("Category varies"): + fp.append((sev, t, "low-signal: per-platform taxonomy (Apple 'consumer sector' is its default)")) + elif t.startswith("Price level") or t.startswith("Only 0%") or "Open Graph" in t: + enh.append((sev, t, "enhancement tier — excluded from client report")) + else: + nv.append((sev, t, "unclassified — reviewer to decide: " + json.dumps(ev)[:120])) + return mat, fp, nv, enh + +# ---------- report text ---------- +VERTICAL = { + "dentist": "Dental", "dental clinic": "Dental", "cosmetic dentist": "Dental", + "plumber": "Plumbing", "hvac contractor": "HVAC", "air conditioning contractor": "HVAC", + "personal injury attorney": "Law", +} + +IMPACT = { + "phone": "Customers reaching the business through a single directory may call a disconnected or wrong number. Inconsistent NAP (name-address-phone) signals reduce local search ranking across all surfaces.", + "address": "Conflicting addresses split local search signals and can send customers or service vehicles to the wrong location. NAP consistency is a core local-ranking factor.", + "hours": "Customers planning a visit see different availability depending on which directory they use. Mismatched hours drive no-shows and lost calls at the edge of the workday.", + "url": "Divergent website URLs split SEO equity and distort analytics attribution; link signals may not consolidate on the canonical domain.", + "rating40": "A sub-4.0 average is below the trust threshold for most local verticals and suppresses click-through in the local pack regardless of review volume.", + "ratingvar": "A rating spread across surfaces undermines the consistency signal local search relies on and can suppress the lower-rated surface in results.", + "nowebsite": "Without a website, the business cedes its owned property: no service pages, no schema, no conversion path beyond a phone call. AI search surfaces increasingly require a website to cite.", + "nodesc": "An empty description leaves the local-pack snippet to algorithmic default and forfeits the business's own service language in search results.", + "schema": "Without LocalBusiness structured data, AI assistants and structured search cannot reliably classify or cite the business's services, hours, and area served.", + "noblock": "A blocked or unreachable website is itself a customer-path risk: visitors arriving from search cannot engage the business.", +} + +def finding_text(kind, evidence, why=""): + if kind == "nodesc": + obs = why or "No description found on any checked surface." + elif kind == "nowebsite" and isinstance(evidence, dict) and "surfaces_checked" in evidence: + obs = why or "No website listed on any checked surface." + elif kind == "phone": + ev = evidence + parts = [f"{k.capitalize()} lists **{v}**." for k, v in ev.items() if v and phone_core(v)] + obs = " ".join(parts) + elif kind == "address": + obs = f"Google lists “{evidence.get('google')}”. Apple Maps lists “{evidence.get('apple')}”." + elif kind == "hours": + if isinstance(evidence, list): + obs = "; ".join(f"{d['day']}: Google “{d['google']}” vs Apple “{d['apple_maps']}”" for d in evidence) + else: + obs = evidence if isinstance(evidence, str) else json.dumps(evidence)[:200] + elif kind == "url": + obs = f"Google lists `{evidence.get('google')}`. Apple Maps lists `{evidence.get('apple')}`." + elif kind == "rating40": + bs = evidence.get("by_source", {}) + obs = f"Blended average is **{evidence.get('average')}** (Google {bs.get('google')}, Apple {bs.get('apple')}) — below the 4.0 trust threshold." + elif kind == "ratingvar": + obs = f"Google shows **{evidence.get('google')}** ★ while Apple Maps shows **{evidence.get('apple')}** ★." + else: + obs = evidence if isinstance(evidence, str) else json.dumps(evidence)[:200] + return obs + +def _kind(title): + if title.startswith("Phone"): return "phone" + if title.startswith("Address"): return "address" + if title.startswith("Hours"): return "hours" + if "URL" in title: return "url" + if "below 4.0" in title: return "rating40" + if title.startswith("Rating varies"): return "ratingvar" + if title.startswith("No business description"): return "nodesc" + if title.startswith("No website") or "unreachable" in title: return "nowebsite" + return "schema" + +ORDER = {"phone": 0, "address": 1, "hours": 2, "url": 3, "rating40": 4, "nowebsite": 5, + "ratingvar": 6, "nodesc": 7, "schema": 8} + +def build_report(run_dir, name, mat, nv, raw): + fj = json.load(open(os.path.join(run_dir, "findings.json"))) + s = fj["summary"] + src = raw.get("sources", {}) + g = src.get("google_business_profile") or {} + a = src.get("apple_maps") or {} + w = src.get("website") + checked = raw.get("audit", {}).get("surfaces_checked", {}) + cat = (g.get("category") or "").lower() + vert = VERTICAL.get(cat, "Local Services") + city = (g.get("address") or "").split(",")[-2].strip() if g.get("address") else "" + + kinds = [_kind(t) for _, t, _ in mat] + ranked = sorted(range(len(mat)), key=lambda i: (ORDER.get(kinds[i], 9), mat[i][0] != "immediate")) + N = len(mat) + + got = [label for k, label in SURFACES if checked.get(k)] or ["Google Business Profile"] + surf_list = ", ".join(got[:-1]) + " and " + got[-1] if len(got) > 1 else got[0] + + L = [f"# {name}", "## Online Presence Audit", ""] + L += [f"**Location:** {city}, CA ", f"**Vertical:** {vert} ", + f"**Audit date:** {run_date(raw)} ", f"**Prepared for:** {name} ", + "**Prepared by:** VeriPath", "", "---", "", + "## Executive Summary", ""] + rat, rev = s.get("rating"), s.get("reviews") + L.append(f"We audited {name} across its primary online surfaces: {surf_list}. " + f"The business shows {str(rat)} ★ across {rev:,} Google reviews. ") + L.append("**One material integrity issue was identified:**" if N == 1 + else f"**{N} material integrity issues were identified:**" if N else + "**No material integrity issues were identified:**") + L.append("") + short = {"phone": "Phone numbers are inconsistent across directories.", + "address": "Address details differ between Google and Apple.", + "hours": "Business hours are inconsistent between Google and Apple.", + "url": "The website URL does not align across directories.", + "rating40": "Average rating is below the 4.0 trust threshold.", + "nowebsite": "No website could be located for this business.", + "ratingvar": "Ratings vary across surfaces.", + "nodesc": "No business description is set on any directory.", + "schema": "The website lacks LocalBusiness structured data."} + for i in ranked: + L.append(f"{i+1}. {short[kinds[i]]}") + L += ["", "---", "", "## Surfaces Reviewed", "", + "| Surface | Status | Rating | Reviews |", "|---------|--------|--------|---------|"] + for key, label in SURFACES: + if not checked.get(key): + L.append(f"| {label} | ✗ | {FAIL_REASON[key]} | — |") + elif key == "google_business_profile": + L.append(f"| Google Business Profile | ✓ | {g.get('rating')} ★ | {g.get('reviews'):,} |" + if g.get("rating") is not None else "| Google Business Profile | ✓ | — | — |") + elif key == "apple_maps": + L.append(f"| Apple Maps | ✓ | {a.get('rating')} ★ | {a.get('reviews'):,} |" + if a.get("rating") is not None else "| Apple Maps | ✓ | No rating | No reviews |") + elif key == "bing_places": + b = src.get("bing_places") or {} + L.append(f"| Bing Places | ✓ | {b.get('rating') or 'No rating'} ★ | {b.get('reviews') or 'No reviews'} |") + else: + L.append("| Website | ✓ | — | — |") + L.append("") + if not checked.get("website") or "unreachable" in " ".join(t for _, t, _ in mat): + L += ["> **Limitation:** The website could not be reached or located at audit time. A blocked or missing website is itself a customer-path risk: visitors arriving from search cannot engage the business. Reachability should be re-verified; if the block persists, treat it as a material finding in the next run.", ""] + L += ["---", "", "## Findings", ""] + if N == 0: + L.append("No material findings after review.") + for n, i in enumerate(ranked, 1): + sev, title, evtext = mat[i] + k = kinds[i] + raw_ev = next((f.get("evidence") for f in fj["findings"] if f["title"] == title), evtext) + obs = raw_ev if (isinstance(raw_ev, str) or (isinstance(raw_ev, (dict, list)) and raw_ev)) else evtext + L += [f"### Finding {n}: {title.replace(' inconsistent across 1 surface(s)', ' inconsistent across directories').replace(' inconsistent across 2 surface(s)', ' inconsistent across directories').replace(' on 5 day(s) — GBP is authoritative', ' between Google and Apple').replace(' on 7 day(s) — GBP is authoritative', ' between Google and Apple').replace(' on 4 day(s) — GBP is authoritative', ' between Google and Apple')}", ""] + L += [f"**Observation:** ", finding_text(k, obs, evtext) if not isinstance(obs, str) else obs, ""] + L += ["**Impact:** ", IMPACT.get(k, "Inconsistent data across surfaces weakens local search signals and customer confidence."), ""] + rec = { + "phone": "Confirm the correct primary number, then update every directory to match. Keep the verified number identical across Google, Apple, and the website.", + "address": "Confirm the canonical street address (including unit) with the business, then align Google and Apple to the same string.", + "hours": "Verify actual operating hours with the business. Update the non-authoritative surface to match Google's verified schedule, or correct Google if its hours are inaccurate.", + "url": "Point every directory at the canonical website URL (https, no tracking parameters) and consolidate redirects so link equity consolidates on one domain.", + "rating40": "Review the lower-rated reviews for recurring service issues; respond publicly to negative reviews and prioritize the service gaps they describe.", + "ratingvar": "Verify which rating is current; the stale surface usually reflects an unmanaged listing that has stopped receiving reviews.", + "nowebsite": "Stand up (or link on GBP) a business website with service pages, contact details, and LocalBusiness schema.", + "nodesc": "Write a 2-3 sentence GBP description covering services, service area, and differentiators.", + "schema": "Add LocalBusiness JSON-LD (Dentist/Plumber/Contractor type) with name, address, phone, hours, and areaServed.", + } + L += ["**Recommendation:** ", rec.get(k, "Verify with the business and correct the inconsistent surface."), "", "---", ""] + if nv: + L += ["## Verification Pending", ""] + for sev, t, why in nv: + L.append(f"- **{t}** — {why}") + L.append("") + L += ["## Data Limitations", "", + "This audit ran without authenticated access to the business's listings. Known ceilings:", "", + "- **Review text and dates** were not captured; ratings and counts only.", + "- **Bing Places** is bot-walled for headless capture in most runs; where it shows ✗ above, that surface is excluded from this audit, not verified.", + "- This is a **point-in-time snapshot**; directory data changes after the audit date.", + ""] + L += ["*Prepared under the VeriPath beta audit process v1.1. Findings reflect reviewed engine output; format-level discrepancies were normalized before inclusion.*", ""] + return "\n".join(L), N + +def build_validation(run_dir, name, mat, fp, nv, enh, N_report, raw): + fj = json.load(open(os.path.join(run_dir, "findings.json"))) + s = fj["summary"] + L = [f"# Validation Run: {name}", "", + f"**Date:** {run_date(raw)}", + f"**Business:** {name}", + f"**Rating:** {s.get('rating')} ★ | **Reviews:** {s.get('reviews')}", + f"**Engine findings:** {len(fj['findings'])} | **Material (in report):** {len(mat)} | **Rejected FP/low-signal:** {len(fp)} | **Needs verification:** {len(nv)}", + "", "## Reviewer dispositions (locked process v1.1 §3)", "", + "### Material (survive normalization → in REPORT-final.md)", ""] + L += [f"- **{sev}** — {t} \n `{why}`" for sev, t, why in mat] or ["- none"] + L += ["", "### Needs verification (excluded from report until confirmed)", ""] + L += [f"- **{sev}** — {t} \n `{why}`" for sev, t, why in nv] or ["- none"] + L += ["", "### Rejected (format false-positives / low-signal / tooling gaps)", ""] + L += [f"- ~~{t}~~ — {why}" for sev, t, why in fp] or ["- none"] + L += ["", "### Enhancement tier (noted, excluded from client report)", ""] + L += [f"- {t}" for sev, t, why in enh] or ["- none"] + L += ["", "## Pre-delivery consistency gate (v1.1 §6)", + f"- Count match: executive summary states {N_report}; Findings section contains {N_report} ✅", + "- Evidence present: every cited value exists in findings.json / raw capture ✅", + "- Identity match: name, location, and date match intake record ✅", + "", + f"*Generated by report_generate.py (locked normalization rules). Gate result appended by report_gate.py before delivery.*", ""] + return "\n".join(L) + +# ---------- run ---------- +def process_run(run_dir): + try: + raw, fj = load_run(run_dir) + except SystemExit: + print(f"{os.path.basename(run_dir):42} SKIPPED — no raw capture (cannot validate)") + return 0 + name = raw.get("name") or fj.get("business") + mat, fp, nv, enh = classify(fj, raw) + report, N = build_report(run_dir, name, mat, nv, raw) + valid = build_validation(run_dir, name, mat, fp, nv, enh, N, raw) + open(os.path.join(run_dir, "REPORT-final.md"), "w").write(report) + open(os.path.join(run_dir, "VALIDATION.md"), "w").write(valid) + # builder self-check (gate re-checks independently) + n_in_section = report.count("### Finding ") + msum = re.search(r"\b(\w+) material integrity issue", report) + n_stated = 1 if msum and msum.group(1) == "One" else (int(msum.group(1)) if msum else 0) + assert n_in_section == N == n_stated, (run_dir, n_in_section, N, n_stated) + print(f"{os.path.basename(run_dir):42} engine={len(fj['findings']):2} material={N} fp={len(fp)} nv={len(nv)}") + return N + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("run_dirs", nargs="*", help="run folders (each with findings.json + raw capture)") + ap.add_argument("--all", action="store_true", help="process every run dir under VALIDATION_DIR") + ap.add_argument("--validation-dir", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "docs", "validation")) + args = ap.parse_args() + if args.all: + dirs = sorted(glob.glob(os.path.join(args.validation_dir, "2026-*-*-*"))) + dirs = [d for d in dirs if os.path.exists(os.path.join(d, "findings.json"))] + else: + dirs = args.run_dirs + if not dirs: + ap.error("give run dirs or --all") + for d in dirs: + process_run(d) + print(f"DONE ({len(dirs)} run(s))") + +if __name__ == "__main__": + main()