feat(auditing): pipeline v1.1 — report generation + scripted gate + temporal delta
- 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)
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
logs/
|
||||
@@ -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.
|
||||
@@ -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/<YYYY-MM-DD>-<slug>/` 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
|
||||
|
||||
@@ -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.*
|
||||
@@ -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.*
|
||||
|
||||
@@ -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.*
|
||||
@@ -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.*
|
||||
|
||||
@@ -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.*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.*
|
||||
@@ -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.*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
+170
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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[@]}"
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user