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 # 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 **Supersedes:** 2026-08-14 draft lock
## Purpose ## Purpose
@@ -39,9 +39,13 @@ Collect:
Run: Run:
```text ```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) ### 3. Human validation (reviewer decision loop)
The reviewer must resolve every engine finding to exactly one disposition. The reviewer must resolve every engine finding to exactly one disposition.
@@ -74,14 +78,21 @@ language or marketing urgency.
### 6. Pre-delivery consistency gate ### 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 1. **Count match:** the executive summary's stated number of findings equals the
number of findings in the Findings section. number of findings in the Findings section.
2. **Evidence present:** every finding's cited evidence exists in the run's 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 3. **Identity match:** report client name, location, and audit date match the
intake record. 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 ### 7. Delivery
@@ -99,6 +110,11 @@ Every run is archived under `docs/validation/<YYYY-MM-DD>-<slug>/` with:
| `findings.md` | engine markdown | | `findings.md` | engine markdown |
| `REPORT-final.md` | client-facing report (post-review) | | `REPORT-final.md` | client-facing report (post-review) |
| `VALIDATION.md` | run notes, surface results, reviewer decisions | | `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 Runs completed before 2026-08-15 are grandfathered as-is. Every run on or
after this date must complete the set before delivery. after this date must complete the set before delivery.
@@ -129,7 +145,8 @@ implementation/auditing/
The standard execution path is: The standard execution path is:
```text ```text
multi_scraper → audit_engine multi_scraper → audit_engine → report_generate → report_gate
audit_diff (re-runs: baseline capture vs. new capture)
``` ```
## Deviations ## 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 # Validation Run: Crystal Blue Plumbing Heating & Air
**Date:** 2026-08-14 **Date:** 14 August 2026
**Business:** Crystal Blue Plumbing Heating & Air **Business:** Crystal Blue Plumbing Heating & Air
**Location:** Placerville, CA (search) — resolved to Loomis, CA (same metro) **Rating:** 4.2 ★ | **Reviews:** 65
**Vertical:** Plumbing / HVAC (GTM-aligned) **Engine findings:** 5 | **Material (in report):** 2 | **Rejected FP/low-signal:** 1 | **Needs verification:** 1
**Run context:** Post-fix A validation — GBP entity-match gate + phone glyph strip.
## Purpose ## Reviewer dispositions (locked process v1.1 §3)
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.
## Surface Results ### Material (survive normalization → in REPORT-final.md)
| Surface | Status | Notes | - **high** — No website found
|---------|--------|-------| `no website listed on Google Business Profile or found on other surfaces`
| Google Business Profile | ⚠️ Rejected | This run the gate rejected GBP's matched entity (`name="Hours"`) and fell back to Apple. No glyph pollution. | - **medium** — No business description found on any surface
| Apple Maps | ✅ Good | Primary source: full NAP, 4.2★/65, hours, website, phone `(916) 571-6448` — glyph-stripped. | `no description on any checked surface — weakens local-pack snippet`
| Bing Places | ❌ Failed | No data returned (known gap). |
| Website | ❌ Failed | HTTP 403 (crystalblueplumbing.com) — distinct from "no website". |
## Fix Verification (Step #2) ### Needs verification (excluded from report until confirmed)
- **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.
## Key Findings - **high** — No Google Maps URL found — listing may be unclaimed
Engine produced findings from the merged contract (Apple primary). The earlier `unclassified — reviewer to decide: {}`
hour-mismatch false positive (GBP "Open 24 hours" vs Apple) is now suppressed
because GBP was rejected, not merged as primary.
## Reliability ### Rejected (format false-positives / low-signal / tooling gaps)
- 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. ✅
## Artifacts - ~~Cannot determine review recency — no dates available in samples~~ — tooling gap: scraper captures no review dates (documented limitation)
- `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
## Verdict ### Enhancement tier (noted, excluded from client report)
Pipeline is production-usable for the next real audits. GBP remains the
highest-value surface; when it degrades, the gate + Apple fallback keep the - Price level not set on GBP or Apple Maps
contract clean instead of poisoned.
## 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 **Date:** 14 August 2026
**Business:** Superior HVAC & Plumbing **Business:** superior plumbing,heating and air conditioning
**Location:** Cameron Park, CA (search) — resolved to Rancho Cucamonga, CA **Rating:** 2.7 ★ | **Reviews:** 57
**Vertical:** HVAC / Plumbing (GTM-aligned) **Engine findings:** 12 | **Material (in report):** 7 | **Rejected FP/low-signal:** 2 | **Needs verification:** 2
**Run context:** Second clean external run (Step B) — 4-surface extract after
the GBP match-gate + phone-glyph fixes.
## Purpose ## Reviewer dispositions (locked process v1.1 §3)
Demonstrate a clean multi-surface run on a distinctive-named business and
capture real cross-source NAP divergence for the audit record.
## Surface Results ### Material (survive normalization → in REPORT-final.md)
| Surface | Status | Notes | - **immediate** — Phone number inconsistent across 2 surface(s)
|---------|--------|-------| `distinct numbers: 9093190746, 9163165708`
| Google Business Profile | ✅ Good | `superior plumbing, heating and air conditioning` — 2.7★/57, full hours, phone `(909) 319-0746` (glyph-stripped), website. | - **immediate** — Address inconsistent across 1 surface(s)
| Apple Maps | ✅ Good | `W&T HVAC & Plumbing Solutions` — null rating/reviews, hours present. | `google='6331 Haven Ave, Rancho Cucamonga, CA 91737' vs apple='1150 Sibley St, Folsom, CA 95630'`
| Bing Places | ✅ Good | `Superior Comfort Heating & Cooling` — 4.5★/10. | - **immediate** — Hours mismatch on 7 day(s) — GBP is authoritative
| Website | ✅ Good | `https://www.superiorplumbingac.com/` (no 403). | `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 ### Needs verification (excluded from report until confirmed)
This is NOT a scraper bug — it is real directory fragmentation the pipeline
correctly captured:
- **Three different business names** across GBP / Apple / Bing. - **high** — Most recent review is 301 days old — no fresh review signal
- **Rating divergence:** 2.7★ (GBP) vs 4.5★ (Bing) vs null (Apple). `unclassified — reviewer to decide: {"newest_review_date": "Oct 17, 2025", "days_ago": 301}`
- **Phone conflict:** GBP `(909) 319-0746` vs Apple `+1 (916) 316-5708`. - **medium** — No canonical URL tag on website
- **Hours conflict:** GBP MonSat 7AM7PM / Sun Closed vs Apple 9AM9PM / `unclassified — reviewer to decide: {"url": "https://www.superiorplumbingac.com/"}`
Sun 10AM5PM.
- **Website conflict:** GBP `superiorplumbingac.com` vs Apple Yelp URL.
These are exactly the inconsistencies a client audit should surface. ### Rejected (format false-positives / low-signal / tooling gaps)
## Fix Verification - ~~Review count varies by 47 across surfaces~~ — low-signal: platforms count reviews differently; not an integrity issue
- **Phone glyph:** GBP phone came out `(909) 319-0746` with no leading - ~~Category varies across surfaces: consumer sector, home service, plumber~~ — low-signal: per-platform taxonomy (Apple 'consumer sector' is its default)
`\ue0b0`. Confirmed `glyph? False`.
- **Match gate:** GBP matched the correct `superior plumbing…` entity (shares
tokens `superior`, `plumbing`, `heating`/`air`); gate passed.
## Reliability ### Enhancement tier (noted, excluded from client report)
- All 4 surfaces returned data. ✅
- Engine flagged name/phone/website/hours mismatches across sources. ✅
- No crashes, valid JSON, findings.md generated. ✅
## Artifacts - Price level not set on GBP or Apple Maps
- `superior_hvac__plumbing_multi_surface_2026-08-14.json` — raw scrape
- `findings.json` — engine output
- `findings.md` — markdown report
- `VALIDATION.md` — this file
## Verdict ## Pre-delivery consistency gate (v1.1 §6)
Clean 4-surface run. Pipeline treats cross-source disagreement as evidence, - Count match: executive summary states 7; Findings section contains 7 ✅
not error. Production-usable. - 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 ## 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:** **3 material integrity issues were identified:**
1. Phone numbers are inconsistent across directories. 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 | | Google Business Profile | ✓ | 4.9 ★ | 166 |
| Apple Maps | ✓ | 4.8 ★ | 27 | | 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. > **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 ### Finding 3: No business description found on any surface
**Observation:** **Observation:**
{"surfaces_checked": ["google", "bing", "website"]} no description on any checked surface — weakens local-pack snippet
**Impact:** **Impact:**
An empty description leaves the local-pack snippet to algorithmic default and forfeits the business's own service language in search results. 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 # Validation Run: Above the Gate Dentistry
**Date:** 2026-08-15 **Date:** 15 August 2026
**Business:** Above the Gate Dentistry **Business:** Above the Gate Dentistry
**Rating:** 4.9 ★ | **Reviews:** 166 **Rating:** 4.9 ★ | **Reviews:** 166
**Engine findings:** 10 | **Material (in report):** 3 | **Rejected FP/low-signal:** 5 | **Needs verification:** 0 **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) ### Material (survive normalization → in REPORT-final.md)
@@ -33,9 +33,19 @@
- Only 0% of sample reviews mention specific services or practitioners - Only 0% of sample reviews mention specific services or practitioners
- Price level not set on GBP or Apple Maps - 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 ✅ - Count match: executive summary states 3; Findings section contains 3 ✅
- Evidence present: every cited value exists in findings.json / raw capture ✅ - Evidence present: every cited value exists in findings.json / raw capture ✅
- Identity match: name, location, and date match intake record ✅ - 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 ## 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:** **5 material integrity issues were identified:**
1. Phone numbers are inconsistent across directories. 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 | | Google Business Profile | ✓ | 4.8 ★ | 1,121 |
| Apple Maps | ✓ | No rating | No reviews | | Apple Maps | ✓ | No rating | No reviews |
| Bing Places | ✗ | capture blocked (bot-wall) — excluded, not verified | — |
| Website | ✓ | — | — | | 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 # Validation Run: AirTech Pros
**Date:** 2026-08-15 **Date:** 15 August 2026
**Business:** AirTech Pros **Business:** AirTech Pros
**Rating:** 4.8 ★ | **Reviews:** 1121 **Rating:** 4.8 ★ | **Reviews:** 1121
**Engine findings:** 8 | **Material (in report):** 5 | **Rejected FP/low-signal:** 2 | **Needs verification:** 0 **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) ### Material (survive normalization → in REPORT-final.md)
@@ -33,9 +33,19 @@
- Price level not set on GBP or Apple Maps - 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 ✅ - Count match: executive summary states 5; Findings section contains 5 ✅
- Evidence present: every cited value exists in findings.json / raw capture ✅ - Evidence present: every cited value exists in findings.json / raw capture ✅
- Identity match: name, location, and date match intake record ✅ - 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 ## 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:** **2 material integrity issues were identified:**
1. Phone numbers are inconsistent across directories. 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 | | Google Business Profile | ✓ | 4.9 ★ | 133 |
| Apple Maps | ✓ | 5 ★ | 13 | | Apple Maps | ✓ | 5 ★ | 13 |
| Bing Places | ✗ | capture blocked (bot-wall) — excluded, not verified | — |
| Website | ✓ | — | — | | 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 - **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 # Validation Run: Cameron Park Family Dentistry
**Date:** 2026-08-15 **Date:** 15 August 2026
**Business:** Cameron Park Family Dentistry **Business:** Cameron Park Family Dentistry
**Rating:** 4.9 ★ | **Reviews:** 133 **Rating:** 4.9 ★ | **Reviews:** 133
**Engine findings:** 9 | **Material (in report):** 2 | **Rejected FP/low-signal:** 4 | **Needs verification:** 1 **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) ### Material (survive normalization → in REPORT-final.md)
@@ -31,9 +31,19 @@
- Only 0% of sample reviews mention specific services or practitioners - Only 0% of sample reviews mention specific services or practitioners
- Price level not set on GBP or Apple Maps - 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 ✅ - Count match: executive summary states 2; Findings section contains 2 ✅
- Evidence present: every cited value exists in findings.json / raw capture ✅ - Evidence present: every cited value exists in findings.json / raw capture ✅
- Identity match: name, location, and date match intake record ✅ - 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 ## 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:** **2 material integrity issues were identified:**
1. Business hours are inconsistent between Google and Apple. 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 # Validation Run: D. Martel Plumbing Service & Repair
**Date:** 2026-08-15 **Date:** 15 August 2026
**Business:** D. Martel Plumbing Service & Repair **Business:** D. Martel Plumbing Service & Repair
**Rating:** 4.7 ★ | **Reviews:** 104 **Rating:** 4.7 ★ | **Reviews:** 104
**Engine findings:** 7 | **Material (in report):** 2 | **Rejected FP/low-signal:** 3 | **Needs verification:** 0 **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) ### Material (survive normalization → in REPORT-final.md)
@@ -29,9 +29,19 @@
- Only 0% of sample reviews mention specific services or practitioners - Only 0% of sample reviews mention specific services or practitioners
- Price level not set on GBP or Apple Maps - 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 ✅ - Count match: executive summary states 2; Findings section contains 2 ✅
- Evidence present: every cited value exists in findings.json / raw capture ✅ - Evidence present: every cited value exists in findings.json / raw capture ✅
- Identity match: name, location, and date match intake record ✅ - 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 ## 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:** **4 material integrity issues were identified:**
1. Phone numbers are inconsistent across directories. 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 | | Google Business Profile | ✓ | 3.4 ★ | 49 |
| Apple Maps | ✓ | 3.3 ★ | 28 | | Apple Maps | ✓ | 3.3 ★ | 28 |
| Bing Places | ✗ | capture blocked (bot-wall) — excluded, not verified | — |
| Website | ✓ | — | — | | 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 # Validation Run: Diamond Springs Dental Center
**Date:** 2026-08-15 **Date:** 15 August 2026
**Business:** Diamond Springs Dental Center **Business:** Diamond Springs Dental Center
**Rating:** 3.4 ★ | **Reviews:** 49 **Rating:** 3.4 ★ | **Reviews:** 49
**Engine findings:** 11 | **Material (in report):** 4 | **Rejected FP/low-signal:** 5 | **Needs verification:** 0 **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) ### Material (survive normalization → in REPORT-final.md)
@@ -35,9 +35,19 @@
- Only 0% of sample reviews mention specific services or practitioners - Only 0% of sample reviews mention specific services or practitioners
- Price level not set on GBP or Apple Maps - 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 ✅ - Count match: executive summary states 4; Findings section contains 4 ✅
- Evidence present: every cited value exists in findings.json / raw capture ✅ - Evidence present: every cited value exists in findings.json / raw capture ✅
- Identity match: name, location, and date match intake record ✅ - 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 ## Online Presence Audit
**Location:** Placerville / El Dorado County, CA **Location:** Antelope, CA
**Vertical:** HVAC **Vertical:** HVAC
**Audit date:** 15 August 2026 **Audit date:** 15 August 2026
**Prepared for:** Gilmore Heating Air and Plumbing **Prepared for:** Gilmore Heating, Air and Plumbing
**Prepared by:** VeriPath **Prepared by:** VeriPath
--- ---
## Executive Summary ## 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. Phone numbers are inconsistent across directories.
2. Address details differ between Google and Apple.
1. Business hours are inconsistent between Google and Apple. 3. Business hours are inconsistent between Google and Apple.
2. The phone number does not align between Google and Apple. 5. The website URL does not align across directories.
3. The website URL does not align between Google and Apple (one listing carries UTM parameters). 4. Average rating is below the 4.0 trust threshold.
7. No website could be located for this business.
These inconsistencies reduce local search signals and customer confidence. 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 | | Google Business Profile | ✓ | 4.8 ★ | 3,860 |
| Apple Maps | ✓ | 2.5 ★ | 272 | | Apple Maps | ✓ | 2.5 ★ | 272 |
| Bing Places | | No rating | No reviews | | Bing Places | | capture blocked (bot-wall) — excluded, not verified | — |
| Website | ✗ | Access blocked | — | | 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 ## Findings
### Finding 1: Hours Mismatch ### Finding 1: Phone number inconsistent across directories
**Observation:** **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:** **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:** **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:** **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:** **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:** **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:** **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:** **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:** **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). **Observation:**
2. **Phone consolidation** — Use single phone number across all directories. 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`.
3. **Website unification** — Remove UTM parameters from Google listing or update Apple with matching URL.
**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 **Date:** 15 August 2026
**Business:** Gilmore Heating Air and Plumbing **Business:** Gilmore Heating, Air and Plumbing
**Location:** Placerville / El Dorado County, CA **Rating:** 4.8 ★ | **Reviews:** 3860
**Vertical:** HVAC **Engine findings:** 13 | **Material (in report):** 8 | **Rejected FP/low-signal:** 3 | **Needs verification:** 0
**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).
## Surface Results ## Reviewer dispositions (locked process v1.1 §3)
| Surface | Status | Notes | ### Material (survive normalization → in REPORT-final.md)
|---------|--------|-------|
| 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 |
## 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 | ### Needs verification (excluded from report until confirmed)
|---------|-------------|-----------|
| #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 |
## Pre-Delivery Consistency Gate - none
1. **Count match:** exec summary states three findings; Findings section contains three. ✅ ### Rejected (format false-positives / low-signal / tooling gaps)
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. ✅
## 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.) ### Enhancement tier (noted, excluded from client report)
- 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`).
## 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` ## Pre-delivery consistency gate (v1.1 §6)
repo (client data boundary), not this repo. `findings.json`/`findings.md` were not - Count match: executive summary states 8; Findings section contains 8 ✅
retained for this pre-lock run; this VALIDATION.md records the reviewer - Evidence present: every cited value exists in findings.json / raw capture ✅
dispositions to satisfy the decision loop. Runs on or after 2026-08-15 must - Identity match: name, location, and date match intake record ✅
retain the full in-repo artifact set per process §Required artifact set.
*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", "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", "business": "Gilmore Heating, Air and Plumbing",
"summary": { "summary": {
"total_findings": 13, "total_findings": 13,
@@ -19,10 +19,10 @@
}, },
"surfaces_ok": { "surfaces_ok": {
"google_business_profile": true, "google_business_profile": true,
"apple_maps": true, "apple_maps": true
"bing_places": true
}, },
"surfaces_failed": { "surfaces_failed": {
"bing_places": false,
"website": false "website": false
}, },
"rating": 4.8, "rating": 4.8,
@@ -43,12 +43,11 @@
{ {
"id": "nap_address_mismatch", "id": "nap_address_mismatch",
"severity": "immediate", "severity": "immediate",
"title": "Address inconsistent across 2 surface(s)", "title": "Address inconsistent across 1 surface(s)",
"sub_score": "Digital Identity", "sub_score": "Digital Identity",
"evidence": { "evidence": {
"google": "8636 Antelope North Rd Bldg D, Antelope, CA 95843", "google": "8636 Antelope North Rd Bldg D, Antelope, CA 95843",
"apple": "8636 Antelope North Rd, Antelope, CA 95843", "apple": "8636 Antelope North Rd, Antelope, CA 95843"
"bing": "4429 Missouri Flat Rd, Placerville, CA 95667"
}, },
"recommendation": "Standardize exact address format. GBP address is authoritative." "recommendation": "Standardize exact address format. GBP address is authoritative."
}, },
@@ -1,7 +1,7 @@
# Audit Findings — Gilmore Heating, Air and Plumbing # Audit Findings — Gilmore Heating, Air and Plumbing
**Generated:** 2026-08-15 14:02 UTC **Generated:** 2026-08-15 16:46 UTC
**Sources checked:** google_business_profile, apple_maps, bing_places **Sources checked:** google_business_profile, apple_maps
**Rating:** 4.8 | **Reviews:** 3860 **Rating:** 4.8 | **Reviews:** 3860
## Severity Summary ## Severity Summary
@@ -28,7 +28,7 @@
} }
``` ```
### Finding #2: Address inconsistent across 2 surface(s) ### Finding #2: Address inconsistent across 1 surface(s)
- **Severity:** 🟥 Immediate - **Severity:** 🟥 Immediate
- **Category:** Digital Identity - **Category:** Digital Identity
@@ -36,8 +36,7 @@
- **Evidence:** ```json - **Evidence:** ```json
{ {
"google": "8636 Antelope North Rd Bldg D, Antelope, CA 95843", "google": "8636 Antelope North Rd Bldg D, Antelope, CA 95843",
"apple": "8636 Antelope North Rd, Antelope, CA 95843", "apple": "8636 Antelope North Rd, Antelope, CA 95843"
"bing": "4429 Missouri Flat Rd, Placerville, CA 95667"
} }
``` ```
@@ -201,4 +200,5 @@
## Failed Surfaces ## Failed Surfaces
- ❌ `bing_places` — no data returned
- ❌ `website` — 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 ## 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:** **2 material integrity issues were identified:**
1. Phone numbers are inconsistent across directories. 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 | | Google Business Profile | ✓ | 5.0 ★ | 283 |
| Apple Maps | ✓ | 4.7 ★ | 41 | | Apple Maps | ✓ | 4.7 ★ | 41 |
| Bing Places | ✗ | capture blocked (bot-wall) — excluded, not verified | — |
| Website | ✓ | — | — | | 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 # Validation Run: Golden Hills Family Dental
**Date:** 2026-08-15 **Date:** 15 August 2026
**Business:** Golden Hills Family Dental **Business:** Golden Hills Family Dental
**Rating:** 5.0 ★ | **Reviews:** 283 **Rating:** 5.0 ★ | **Reviews:** 283
**Engine findings:** 10 | **Material (in report):** 2 | **Rejected FP/low-signal:** 5 | **Needs verification:** 0 **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) ### Material (survive normalization → in REPORT-final.md)
@@ -32,9 +32,19 @@
- Price level not set on GBP or Apple Maps - Price level not set on GBP or Apple Maps
- No Open Graph tags on website - 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 ✅ - Count match: executive summary states 2; Findings section contains 2 ✅
- Evidence present: every cited value exists in findings.json / raw capture ✅ - Evidence present: every cited value exists in findings.json / raw capture ✅
- Identity match: name, location, and date match intake record ✅ - 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 ## 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:** **4 material integrity issues were identified:**
1. Phone numbers are inconsistent across directories. 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 | | Google Business Profile | ✓ | 4.9 ★ | 45 |
| Apple Maps | ✓ | No rating | No reviews | | 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. > **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 ### Finding 4: No business description found on any surface
**Observation:** **Observation:**
{"surfaces_checked": ["google", "bing", "website"]} no description on any checked surface — weakens local-pack snippet
**Impact:** **Impact:**
An empty description leaves the local-pack snippet to algorithmic default and forfeits the business's own service language in search results. 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 # Validation Run: Gordon Law
**Date:** 2026-08-15 **Date:** 15 August 2026
**Business:** Gordon Law **Business:** Gordon Law
**Rating:** 4.9 ★ | **Reviews:** 45 **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) ### Material (survive normalization → in REPORT-final.md)
@@ -20,11 +20,11 @@
### Needs verification (excluded from report until confirmed) ### 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) ### 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) - ~~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) - ~~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 - 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 ✅ - Count match: executive summary states 4; Findings section contains 4 ✅
- Evidence present: every cited value exists in findings.json / raw capture ✅ - Evidence present: every cited value exists in findings.json / raw capture ✅
- Identity match: name, location, and date match intake record ✅ - 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 ## 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:** **3 material integrity issues were identified:**
1. Phone numbers are inconsistent across directories. 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 | | Google Business Profile | ✓ | 4.8 ★ | 180 |
| Apple Maps | ✓ | 4.1 ★ | 37 | | Apple Maps | ✓ | 4.1 ★ | 37 |
| Bing Places | ✗ | capture blocked (bot-wall) — excluded, not verified | — |
| Website | ✓ | — | — | | 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 - **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 # Validation Run: Scotty's Heating & Air
**Date:** 2026-08-15 **Date:** 15 August 2026
**Business:** Scotty's Heating & Air **Business:** Scotty's Heating & Air
**Rating:** 4.8 ★ | **Reviews:** 180 **Rating:** 4.8 ★ | **Reviews:** 180
**Engine findings:** 10 | **Material (in report):** 3 | **Rejected FP/low-signal:** 4 | **Needs verification:** 1 **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) ### Material (survive normalization → in REPORT-final.md)
@@ -33,9 +33,19 @@
- Only 0% of sample reviews mention specific services or practitioners - Only 0% of sample reviews mention specific services or practitioners
- Price level not set on GBP or Apple Maps - 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 ✅ - Count match: executive summary states 3; Findings section contains 3 ✅
- Evidence present: every cited value exists in findings.json / raw capture ✅ - Evidence present: every cited value exists in findings.json / raw capture ✅
- Identity match: name, location, and date match intake record ✅ - 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]}" BUSINESS="${1:?Usage: ./audit_pipeline.sh \"Business Name\" \"City, ST\" [output-dir]}"
LOCATION="${2:?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:-}" 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 # Temporary working dir for this run
WORK_DIR=$(mktemp -d /tmp/veripath.XXXXXX) WORK_DIR=$(mktemp -d /tmp/veripath.XXXXXX)
@@ -30,12 +35,30 @@ echo "[OK] Extraction complete: $SCRAPER_OUT"
# Step 2: Audit engine # Step 2: Audit engine
if [ -n "$OUTPUT_DIR" ]; then if [ -n "$OUTPUT_DIR" ]; then
mkdir -p "$OUTPUT_DIR" mkdir -p "$OUTPUT_DIR"
# Required artifact set (locked beta process v1.0): raw capture + findings in the run folder # Required artifact set (locked beta process v1.1): raw capture + findings in the run folder
cp "$SCRAPER_OUT" "$OUTPUT_DIR/" # 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") ENGINE_ARGS+=("--output-dir" "$OUTPUT_DIR")
echo "[$(date +%T)] Auditing (output: $OUTPUT_DIR)" echo "[$(date +%T)] Auditing (output: $OUTPUT_DIR)"
uv run python3 "$SCRIPT_DIR/audit_engine.py" "$SCRAPER_OUT" "${ENGINE_ARGS[@]}" 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 else
echo "[$(date +%T)] Auditing (stdout)" echo "[$(date +%T)] Auditing (stdout)"
uv run python3 "$SCRIPT_DIR/audit_engine.py" "$SCRAPER_OUT" "${ENGINE_ARGS[@]}" 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()