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:
2026-08-15 16:52:23 +00:00
parent c951f79a18
commit 394d5dae3f
33 changed files with 1681 additions and 228 deletions
+3
View File
@@ -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 34 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.
+22 -5
View File
@@ -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.74.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.74.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 MonSat 7AM7PM / Sun Closed vs Apple 9AM9PM /
Sun 10AM5PM.
- **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. Googles 24-hour listing suggests emergency service availability; Apples 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 Googles 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. Googles listing includes UTM parameters; Apples 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 Heatings 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.54.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.54.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 AM12 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
@@ -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
+100
View File
@@ -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()
+26 -3
View File
@@ -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[@]}"
+208
View File
@@ -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()
+425
View File
@@ -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()