diff --git a/implementation/auditing/audit_engine.py b/implementation/auditing/audit_engine.py new file mode 100644 index 0000000..d416fde --- /dev/null +++ b/implementation/auditing/audit_engine.py @@ -0,0 +1,824 @@ +#!/usr/bin/env python3 +""" +VeriPath Audit Engine v1 — takes raw multi-scraper JSON, produces structured findings. + +Each finding has: + - id: unique slug (e.g. "nap_phone_mismatch") + - severity: "immediate" | "high" | "medium" | "enhancement" + - title: one-line summary + - sub_score: one of the 7 salon-audit-v2 sub-scores + - evidence: dict of raw source data backing the finding + - recommendation: concrete fix + +Runs standalone: python audit_engine.py [--json] [--md] + +Output feeds directly into salon-audit-v2 skill for report generation. +""" +import json +import re +import sys +import os +from datetime import datetime, timezone +from difflib import SequenceMatcher + +# ── Sub-score categories (salon-audit-v2) ────────────────────────────── +DIGITAL_IDENTITY = "Digital Identity" +TECHNICAL_SEO = "Technical SEO" +CONVERSION = "Conversion" +GOOGLE_BUSINESS = "Google Business" +REVIEWS = "Reviews" +CONTENT = "Content" +AUTHORITY = "Authority" + +SEVERITY_IMMEDIATE = "immediate" # 🟥 +SEVERITY_HIGH = "high" # 🟧 +SEVERITY_MEDIUM = "medium" # 🟨 +SEVERITY_ENHANCEMENT = "enhancement" # 🟩 + + +def _safe_get(data, *keys, default=None): + """Nested dict access without KeyError.""" + if not keys: + return default + cur = data + for k in keys: + if isinstance(cur, dict): + cur = cur.get(k) + if cur is None: + return default + else: + return default + return cur + + +def _normalize_phone(phone): + """Strip to digits, normalize US country code for comparison.""" + if not phone: + return "" + d = re.sub(r'[^\d]', '', phone) + # Strip leading 1 (US country code) so +1-530... and 530... match + if d.startswith('1') and len(d) == 11: + d = d[1:] + return d + + +def _normalize_address(addr): + """Lowercase, strip extra whitespace, remove suite abbrev variances.""" + if not addr: + return "" + s = re.sub(r'[\U000E0000-\U000EFFFF]', '', addr or '') + s = re.sub(r'\s+', ' ', s.strip().lower()) + s = re.sub(r'\bst[e]*\.?\b', 'ste', s) + return s + + +def _normalize_url(url): + """Strip trailing slashes, normalize www, lowercase.""" + if not url: + return "" + u = url.strip().lower().rstrip('/') + return u.replace('http://www.', 'http://').replace('https://www.', 'https://') + + +def _hours_overlap(a, b): + """Check if two hours strings describe the same hours (normalize format).""" + if not a or not b: + return True # can't compare + a_norm = re.sub(r'\s+', '', a).lower().replace(':', '').replace(' ', '') + b_norm = re.sub(r'\s+', '', b).lower().replace(':', '').replace(' ', '') + return a_norm == b_norm + + +def _review_dates_from_samples(samples): + """Extract dates from review samples across all sources.""" + dates = [] + for src_samples in samples: + if not src_samples: + continue + for r in src_samples: + d = r.get('date', '') + if d: + dates.append(d) + return dates + + +def _parse_review_date(date_str): + """Try to parse a review date string to a datetime. Returns None on fail.""" + for fmt in ("%b %d, %Y", "%B %d, %Y", "%Y-%m-%d", "%m/%d/%Y"): + try: + return datetime.strptime(date_str.strip(), fmt) + except (ValueError, AttributeError): + continue + return None + + +# ── Check functions (each returns list of findings) ──────────────────── + +def check_nap_phone(contract): + """NAP: phone consistency across surfaces.""" + findings = [] + phones = {} + for src_name, src_data in [("google", "google_business_profile"), + ("apple", "apple_maps"), + ("bing", "bing_places"), + ("website", "website")]: + data = _safe_get(contract, "sources", src_data, default={}) + if not data: + continue + raw = data.get('phone') or data.get('phone_on_page') + if raw: + phones[src_name] = raw + + if len(phones) < 2: + return findings + + normalized = {k: _normalize_phone(v) for k, v in phones.items()} + first_val = list(normalized.values())[0] + mismatches = {k: v for k, v in normalized.items() if v != first_val} + + if mismatches: + findings.append({ + "id": "nap_phone_mismatch", + "severity": SEVERITY_IMMEDIATE, + "title": f"Phone number inconsistent across {len(mismatches)} surface(s)", + "sub_score": DIGITAL_IDENTITY, + "evidence": {src: phones.get(src, "N/A") for src in phones}, + "recommendation": "Standardize phone format across all directories. GBP is authoritative." + }) + + return findings + + +def check_nap_address(contract): + """NAP: address consistency across surfaces.""" + findings = [] + addresses = {} + for src_name, src_data in [("google", "google_business_profile"), + ("apple", "apple_maps"), + ("bing", "bing_places")]: + data = _safe_get(contract, "sources", src_data, default={}) + raw = data.get('address') + if raw: + addresses[src_name] = raw + + if len(addresses) < 2: + return findings + + normalized = {k: _normalize_address(v) for k, v in addresses.items()} + first_val = list(normalized.values())[0] + mismatches = {k: v for k, v in normalized.items() if v != first_val} + + if mismatches: + findings.append({ + "id": "nap_address_mismatch", + "severity": SEVERITY_IMMEDIATE, + "title": f"Address inconsistent across {len(mismatches)} surface(s)", + "sub_score": DIGITAL_IDENTITY, + "evidence": {src: addresses.get(src, "N/A") for src in addresses}, + "recommendation": "Standardize exact address format. GBP address is authoritative." + }) + + return findings + + +def check_nap_website(contract): + """NAP: website URL consistency.""" + findings = [] + websites = {} + for src_name, src_data in [("google", "google_business_profile"), + ("apple", "apple_maps")]: + data = _safe_get(contract, "sources", src_data, default={}) + raw = data.get('website') + if raw: + websites[src_name] = raw + + if len(websites) < 2: + return findings + + normalized = {k: _normalize_url(v) for k, v in websites.items()} + first_val = list(normalized.values())[0] + mismatches = {k: v for k, v in normalized.items() if v != first_val} + + if mismatches: + findings.append({ + "id": "nap_website_mismatch", + "severity": SEVERITY_HIGH, + "title": f"Website URL inconsistent across {len(mismatches)} surface(s)", + "sub_score": DIGITAL_IDENTITY, + "evidence": {src: websites.get(src, "N/A") for src in websites}, + "recommendation": "Use one canonical URL (prefer HTTPS, no www) across all directories." + }) + + return findings + + +def check_hours_mismatch(contract): + """Hours consistency. GBP is authoritative.""" + findings = [] + gbp_hours = _safe_get(contract, "sources", "google_business_profile", "hours", default={}) + apple_hours = _safe_get(contract, "sources", "apple_maps", "hours", default={}) + bing_hours = _safe_get(contract, "sources", "bing_places", "hours", default={}) + + if not gbp_hours: + return findings # nothing to compare against + + mismatched_days = [] + for other_name, other_hours in [("apple_maps", apple_hours), ("bing_places", bing_hours)]: + if not other_hours: + continue + for day, gbp_val in gbp_hours.items(): + if day in other_hours: + other_val = other_hours[day] + if not _hours_overlap(gbp_val, other_val): + mismatched_days.append({ + "day": day, + "google": gbp_val, + other_name: other_val + }) + + if mismatched_days: + findings.append({ + "id": "hours_mismatch", + "severity": SEVERITY_IMMEDIATE, + "title": f"Hours mismatch on {len(mismatched_days)} day(s) — GBP is authoritative", + "sub_score": GOOGLE_BUSINESS, + "evidence": mismatched_days, + "recommendation": "Correct hours on non-GBP surfaces to match GBP. Mismatched hours cause customer complaints and trust loss." + }) + + return findings + + +def check_rating_health(contract): + """Rating delta across surfaces and overall health.""" + findings = [] + ratings = {} + for src_name, src_data in [("google", "google_business_profile"), + ("apple", "apple_maps"), + ("bing", "bing_places")]: + data = _safe_get(contract, "sources", src_data, default={}) + r = data.get('rating') + if r is not None: + ratings[src_name] = float(r) + + if not ratings: + return findings + + avg = sum(ratings.values()) / len(ratings) + delta = max(ratings.values()) - min(ratings.values()) + + if delta > 0.3: + findings.append({ + "id": "rating_delta_high", + "severity": SEVERITY_HIGH, + "title": f"Rating varies by {delta:.1f} across surfaces (range: {min(ratings.values()):.1f}–{max(ratings.values()):.1f})", + "sub_score": REVIEWS, + "evidence": ratings, + "recommendation": "Investigate why some surfaces show different ratings. May indicate stale data or mixed-up business listings." + }) + + if avg < 4.0: + findings.append({ + "id": "rating_below_threshold", + "severity": SEVERITY_IMMEDIATE, + "title": f"Average rating {avg:.1f} — below 4.0 threshold", + "sub_score": REVIEWS, + "evidence": {"average": round(avg, 2), "by_source": ratings}, + "recommendation": "Address negative reviews, request new reviews from satisfied customers. Below 4.0 is a strong conversion blocker." + }) + + return findings + + +def check_review_count_delta(contract): + """Review count variation across surfaces.""" + findings = [] + counts = {} + for src_name, src_data in [("google", "google_business_profile"), + ("apple", "apple_maps"), + ("bing", "bing_places")]: + data = _safe_get(contract, "sources", src_data, default={}) + c = data.get('reviews') + if c is not None: + counts[src_name] = int(c) + + if len(counts) < 2: + return findings + + delta = max(counts.values()) - min(counts.values()) + if delta > 5: + findings.append({ + "id": "review_count_delta", + "severity": SEVERITY_MEDIUM, + "title": f"Review count varies by {delta} across surfaces", + "sub_score": REVIEWS, + "evidence": counts, + "recommendation": "Large review count gaps suggest some surfaces have stale or merged data. Verify listing ownership." + }) + + return findings + + +def check_review_velocity(contract): + """Review recency — are recent reviews flowing?""" + findings = [] + review_samples = [ + _safe_get(contract, "sources", "apple_maps", "reviews_sample", default=[]), + _safe_get(contract, "sources", "bing_places", "reviews_sample", default=[]), + ] + + dates = _review_dates_from_samples(review_samples) + if not dates: + findings.append({ + "id": "review_dates_unavailable", + "severity": SEVERITY_MEDIUM, + "title": "Cannot determine review recency — no dates available in samples", + "sub_score": REVIEWS, + "evidence": {"sources_with_dates": []}, + "recommendation": "Review date data is not available from current surfaces. Monitor via GBP dashboard directly." + }) + return findings + + parsed = [_parse_review_date(d) for d in dates] + parsed = [p for p in parsed if p is not None] + + if not parsed: + return findings + + newest = max(parsed) + now = datetime.now(timezone.utc).replace(tzinfo=None) + days_since_newest = (now - newest).days + + if days_since_newest > 90: + findings.append({ + "id": "reviews_stale", + "severity": SEVERITY_HIGH, + "title": f"Most recent review is {days_since_newest} days old — no fresh review signal", + "sub_score": REVIEWS, + "evidence": {"newest_review_date": dates[0], "days_ago": days_since_newest}, + "recommendation": "No recent reviews = no social proof for new visitors. Implement review request workflow post-appointment." + }) + + return findings + + +def check_review_text_quality(contract): + """Check if reviews mention specific services/practitioners (E-E-A-T signal).""" + findings = [] + all_samples = [] + for src in ["apple_maps", "bing_places"]: + samples = _safe_get(contract, "sources", src, "reviews_sample", default=[]) + all_samples.extend(samples or []) + + if not all_samples: + return findings + + specific_signals = 0 + for r in all_samples: + text = (r.get('text') or '').lower() + if any(word in text for word in ['ashley', 'grace', 'mallory', 'facial', 'hair', 'waxing', 'lashes', 'spray tan', 'massage']): + specific_signals += 1 + + ratio = specific_signals / len(all_samples) if all_samples else 0 + if ratio < 0.5 and len(all_samples) >= 3: + findings.append({ + "id": "reviews_low_specificity", + "severity": SEVERITY_ENHANCEMENT, + "title": f"Only {ratio:.0%} of sample reviews mention specific services or practitioners", + "sub_score": REVIEWS, + "evidence": {"sample_size": len(all_samples), "specific_count": specific_signals}, + "recommendation": "Encourage detailed reviews mentioning services and staff. Specific reviews rank higher and convert better." + }) + + return findings + + +def check_category_alignment(contract): + """Category consistency and fragmentation across surfaces.""" + findings = [] + categories = {} + for src_name, src_data in [("google", "google_business_profile"), + ("apple", "apple_maps"), + ("bing", "bing_places")]: + data = _safe_get(contract, "sources", src_data, default={}) + cat = data.get('category') + if cat: + categories[src_name] = cat.lower().strip() + + if len(categories) < 2: + return findings + + unique = set(categories.values()) + if len(unique) > 1: + findings.append({ + "id": "category_fragmentation", + "severity": SEVERITY_MEDIUM, + "title": f"Category varies across surfaces: {', '.join(sorted(unique))}", + "sub_score": DIGITAL_IDENTITY, + "evidence": categories, + "recommendation": "Align primary category across all directories. GBP primary category is the ranking driver." + }) + + return findings + + +def check_closed_status(contract): + """Check if GBP shows any closed status.""" + findings = [] + closed = _safe_get(contract, "closed_status", default={}) + + if closed.get('permanently_closed'): + findings.append({ + "id": "gbp_permanently_closed", + "severity": SEVERITY_IMMEDIATE, + "title": "GBP marked as permanently closed", + "sub_score": GOOGLE_BUSINESS, + "evidence": closed, + "recommendation": "This kills all local visibility. Reopen the GBP immediately via google.com/business." + }) + + if closed.get('temporarily_closed'): + findings.append({ + "id": "gbp_temporarily_closed", + "severity": SEVERITY_HIGH, + "title": "GBP marked as temporarily closed", + "sub_score": GOOGLE_BUSINESS, + "evidence": closed, + "recommendation": "Remove temporary closure status. Temp-closed listings lose ranking and impressions." + }) + + return findings + + +def check_description(contract): + """Check if any surface has a business description.""" + findings = [] + has_desc = False + for src in ["google_business_profile", "bing_places", "website"]: + data = _safe_get(contract, "sources", src, default={}) + if data: + desc = data.get('description') or data.get('description') + if desc and len(desc) > 20: + has_desc = True + break + + if not has_desc: + findings.append({ + "id": "no_description", + "severity": SEVERITY_MEDIUM, + "title": "No business description found on any surface", + "sub_score": CONTENT, + "evidence": {"surfaces_checked": ["google", "bing", "website"]}, + "recommendation": "Add a keyword-rich description to GBP and website. Descriptions improve click-through and category relevance." + }) + + return findings + + +def check_price_level(contract): + """Check if price level is available.""" + findings = [] + has_price = False + for src in ["google_business_profile", "apple_maps"]: + data = _safe_get(contract, "sources", src, default={}) + if data and data.get('price_level'): + has_price = True + break + + if not has_price: + findings.append({ + "id": "no_price_level", + "severity": SEVERITY_ENHANCEMENT, + "title": "Price level not set on GBP or Apple Maps", + "sub_score": CONTENT, + "evidence": {}, + "recommendation": "Set price level ($–$$$$) on GBP. Helps users self-select and improves relevance." + }) + + return findings + + +def check_photos(contract): + """Check if photo count is available.""" + findings = [] + photos = _safe_get(contract, "sources", "google_business_profile", "photos_count") + + if photos is not None and photos == 0: + findings.append({ + "id": "no_photos", + "severity": SEVERITY_HIGH, + "title": "GBP has zero photos", + "sub_score": GOOGLE_BUSINESS, + "evidence": {"photos_count": 0}, + "recommendation": "Upload 20+ photos: exterior, interior, team, services, before/after. Photos increase booking by 42%." + }) + elif photos is not None and 0 < photos < 10: + findings.append({ + "id": "low_photos", + "severity": SEVERITY_MEDIUM, + "title": f"GBP has only {photos} photo(s)", + "sub_score": GOOGLE_BUSINESS, + "evidence": {"photos_count": photos}, + "recommendation": "Aim for 20+ photos. More photos = more engagement and trust." + }) + + return findings + + +def check_website_schema(contract): + """Check if website has JSON-LD schema (LocalBusiness/BeautySalon/etc).""" + findings = [] + website_url = _safe_get(contract, "sources", "website", "source_url") + if not website_url: + findings.append({ + "id": "no_website", + "severity": SEVERITY_HIGH, + "title": "No website found", + "sub_score": TECHNICAL_SEO, + "evidence": {}, + "recommendation": "A website is essential for credibility, schema markup, and conversion. Launch one immediately." + }) + return findings + + # Fetch and check for JSON-LD + try: + import urllib.request + req = urllib.request.Request(website_url, headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(req, timeout=10) as resp: + html = resp.read().decode("utf-8", errors="replace") + + # Check for JSON-LD + has_jsonld = bool(re.search(r']*type=["\']application/ld\+json["\']', html, re.IGNORECASE)) + has_local_business = bool(re.search(r'"@type"\s*:\s*"(?:LocalBusiness|BeautySalon|DaySpa|HealthAndBeautyBusiness)', html, re.IGNORECASE)) + + # Check for meta canonical + has_canonical = bool(re.search(r']*rel=["\']canonical["\']', html, re.IGNORECASE)) + + # Check for OG tags + has_og = bool(re.search(r']*property=["\']og:', html, re.IGNORECASE)) + + if not has_jsonld: + findings.append({ + "id": "missing_jsonld_schema", + "severity": SEVERITY_HIGH, + "title": "No JSON-LD schema found on website", + "sub_score": TECHNICAL_SEO, + "evidence": {"url": website_url, "has_jsonld": False}, + "recommendation": "Add LocalBusiness/BeautySalon JSON-LD schema. Critical for rich results and local ranking." + }) + elif not has_local_business: + findings.append({ + "id": "schema_wrong_type", + "severity": SEVERITY_MEDIUM, + "title": "JSON-LD present but no LocalBusiness/BeautySalon type", + "sub_score": TECHNICAL_SEO, + "evidence": {"url": website_url, "has_jsonld": True, "has_local_business": False}, + "recommendation": "Ensure schema includes LocalBusiness or BeautySalon @type for local search relevance." + }) + + if not has_canonical: + findings.append({ + "id": "missing_canonical", + "severity": SEVERITY_MEDIUM, + "title": "No canonical URL tag on website", + "sub_score": TECHNICAL_SEO, + "evidence": {"url": website_url}, + "recommendation": "Add to prevent duplicate content issues." + }) + + if not has_og: + findings.append({ + "id": "missing_og_tags", + "severity": SEVERITY_ENHANCEMENT, + "title": "No Open Graph tags on website", + "sub_score": TECHNICAL_SEO, + "evidence": {"url": website_url}, + "recommendation": "Add OG tags for proper link preview on social media and messaging apps." + }) + + except Exception as e: + findings.append({ + "id": "website_unreachable", + "severity": SEVERITY_HIGH, + "title": f"Website fetch failed: {e}", + "sub_score": TECHNICAL_SEO, + "evidence": {"url": website_url, "error": str(e)}, + "recommendation": "Verify website is live and accessible. A broken site kills all local signals." + }) + + return findings + + +def check_google_maps_url(contract): + """Check if GBP has a proper Google Maps URL.""" + findings = [] + gmaps_url = _safe_get(contract, "sources", "google_business_profile", "url") + if not gmaps_url: + findings.append({ + "id": "no_gmaps_url", + "severity": SEVERITY_HIGH, + "title": "No Google Maps URL found — listing may be unclaimed", + "sub_score": GOOGLE_BUSINESS, + "evidence": {}, + "recommendation": "Claim and verify the GBP at google.com/business. Unclaimed listings cannot be managed." + }) + return findings + + +def check_coordinates_precision(contract): + """Check if coordinates are precise enough (pin dropped vs exact).""" + findings = [] + coords = _safe_get(contract, "coordinates", default={}) + lat = coords.get('lat') + lon = coords.get('lon') + + if lat and lon: + # Pin-dropped pins usually have fewer decimal places + lat_precision = len(str(lat).split('.')[-1]) if '.' in str(lat) else 0 + lon_precision = len(str(lon).split('.')[-1]) if '.' in str(lon) else 0 + if min(lat_precision, lon_precision) < 6: + findings.append({ + "id": "low_coordinate_precision", + "severity": SEVERITY_MEDIUM, + "title": f"Coordinates may be pin-dropped ({lat_precision}/{lon_precision} decimal places)", + "sub_score": GOOGLE_BUSINESS, + "evidence": {"lat": lat, "lon": lon, "lat_precision": lat_precision, "lon_precision": lon_precision}, + "recommendation": "Set exact pin location on GBP. Pin-dropped locations lose precision ranking." + }) + + return findings + + +# ── Main engine ────────────────────────────────────────────────────────── + +ALL_CHECKS = [ + check_nap_phone, + check_nap_address, + check_nap_website, + check_hours_mismatch, + check_rating_health, + check_review_count_delta, + check_review_velocity, + check_review_text_quality, + check_category_alignment, + check_closed_status, + check_description, + check_price_level, + check_photos, + check_website_schema, + check_google_maps_url, + check_coordinates_precision, +] + +SEVERITY_ORDER = { + SEVERITY_IMMEDIATE: 0, + SEVERITY_HIGH: 1, + SEVERITY_MEDIUM: 2, + SEVERITY_ENHANCEMENT: 3, +} + + +def analyze(contract): + """Run all checks on a multi-scraper contract. Returns sorted findings list.""" + all_findings = [] + for check_fn in ALL_CHECKS: + try: + findings = check_fn(contract) + all_findings.extend(findings) + except Exception as e: + print(f" [WARN] {check_fn.__name__} failed: {e}", file=sys.stderr) + + # Deduplicate by id + seen = set() + deduped = [] + for f in all_findings: + if f["id"] not in seen: + seen.add(f["id"]) + deduped.append(f) + + # Sort by severity + deduped.sort(key=lambda x: SEVERITY_ORDER.get(x["severity"], 99)) + return deduped + + +def summarize(findings, contract): + """Return a summary dict with counts and scores.""" + counts = {"immediate": 0, "high": 0, "medium": 0, "enhancement": 0} + for f in findings: + counts[f["severity"]] = counts.get(f["severity"], 0) + 1 + + # Sub-score health (count findings per sub-score) + sub_scores = {} + for f in findings: + ss = f["sub_score"] + sub_scores[ss] = sub_scores.get(ss, 0) + 1 + + surfaces = _safe_get(contract, "audit", "surfaces_checked", default={}) + rating = _safe_get(contract, "rating") + reviews = _safe_get(contract, "reviews") + + return { + "total_findings": len(findings), + "severity_counts": counts, + "sub_score_issues": sub_scores, + "surfaces_ok": {k: v for k, v in surfaces.items() if v}, + "surfaces_failed": {k: v for k, v in surfaces.items() if not v}, + "rating": rating, + "reviews": reviews, + } + + +def to_markdown(findings, summary, contract): + """Render findings as markdown for salon-audit-v2 consumption.""" + lines = [] + name = contract.get("name", "Unknown") + lines.append(f"# Audit Findings — {name}") + lines.append("") + lines.append(f"**Generated:** {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}") + lines.append(f"**Sources checked:** {', '.join(summary['surfaces_ok'].keys()) or 'None'}") + lines.append(f"**Rating:** {contract.get('rating', 'N/A')} | **Reviews:** {contract.get('reviews', 'N/A')}") + lines.append("") + + # Severity summary + icons = {"immediate": "🟥", "high": "🟧", "medium": "🟨", "enhancement": "🟩"} + sc = summary["severity_counts"] + lines.append("## Severity Summary") + lines.append("") + lines.append(f"| Severity | Count |") + lines.append(f"|----------|-------|") + for sev in ["immediate", "high", "medium", "enhancement"]: + lines.append(f"| {icons.get(sev, '')} {sev.capitalize()} | {sc.get(sev, 0)} |") + lines.append(f"| **Total** | **{summary['total_findings']}** |") + lines.append("") + + # Findings + lines.append("## Findings") + lines.append("") + + for i, f in enumerate(findings, 1): + icon = icons.get(f["severity"], "") + lines.append(f"### Finding #{i}: {f['title']}") + lines.append("") + lines.append(f"- **Severity:** {icon} {f['severity'].capitalize()}") + lines.append(f"- **Category:** {f['sub_score']}") + lines.append(f"- **Recommendation:** {f['recommendation']}") + if f.get("evidence"): + lines.append(f"- **Evidence:** ```json\n{json.dumps(f['evidence'], indent=2)}\n```") + lines.append("") + + # Surfaces that failed + if summary["surfaces_failed"]: + lines.append("## Failed Surfaces") + lines.append("") + for src in summary["surfaces_failed"]: + lines.append(f"- ❌ `{src}` — no data returned") + lines.append("") + + return "\n".join(lines) + + +def main(): + if len(sys.argv) < 2: + print("Usage: audit_engine.py [--json] [--md]") + sys.exit(1) + + filepath = sys.argv[1] + output_json = "--json" in sys.argv + output_md = "--md" in sys.argv + + if not os.path.exists(filepath): + print(f"Error: {filepath} not found") + sys.exit(1) + + with open(filepath) as f: + contract = json.load(f) + + findings = analyze(contract) + summary = summarize(findings, contract) + + if output_md: + md = to_markdown(findings, summary, contract) + print(md) + elif output_json: + result = { + "audit_engine": "v1", + "timestamp": datetime.now(timezone.utc).isoformat(), + "business": _safe_get(contract, "name"), + "summary": summary, + "findings": findings, + } + print(json.dumps(result, indent=2)) + else: + # Default: JSON + result = { + "audit_engine": "v1", + "timestamp": datetime.now(timezone.utc).isoformat(), + "business": _safe_get(contract, "name"), + "summary": summary, + "findings": findings, + } + print(json.dumps(result, indent=2)) + + +if __name__ == "__main__": + main()