Files
veripath/implementation/auditing/report_generate.py
T
Leonard 394d5dae3f 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)
2026-08-15 16:52:23 +00:00

426 lines
24 KiB
Python

#!/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()