File size: 9,307 Bytes
68b2aca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e07e661
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68b2aca
e07e661
 
68b2aca
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
"""Coverage audit β€” answers "is every useful thing on this site actually ingested?"
empirically, for BOTH platform stores (Shopify / WooCommerce β€” 'a website inside
another') AND normal/hand-rolled sites (sitemap + schema.org).

It enumerates the LIVE site's askable universe and checks each part against what the
ingest pipeline captures (build_catalog_docs), then prints coverage % + the exact
gaps. MEASURED only β€” no projections.

  python evals/coverage_audit.py                      # default store set
  python evals/coverage_audit.py https://babyfy.pk/   # one site

Universe audited per site:
  β€’ products    β€” every product in the structured feed β†’ ingested? priced?
  β€’ collections β€” every storefront collection (sidebar/menu group) β†’ members captured?
  β€’ key pages   β€” shipping / returns / refund / privacy / terms / about / contact / faq
                  exist on the site? (prose pages need the CRAWL, not the catalog feed β€”
                  flagged so you know what the crawler must reach.)
"""
import sys, os, re, urllib.parse, html as _html
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import requests
from services.catalog_api import (
    UA, fetch_all_products, fetch_all_products_woo, fetch_all_products_generic,
    fetch_collection_map, build_catalog_docs, _sitemap_product_urls, prime_feed_snapshot,
)

# Cache each live feed for the whole run so detect + universe + build_catalog_docs
# share ONE fetch per (feed, site) β€” cheaper and not flaky on a transient empty.
prime_feed_snapshot(True)

DEFAULT_SITES = [
    "https://babyfy.pk/",                                  # Shopify
    "https://thestationerycompany.pk",                     # Shopify (large, 234 collections)
    "https://stationerystudio.pk",                         # Shopify
    "https://webscraper.io/test-sites/e-commerce/allinone",# generic / normal site
]

KEY_PAGES = {
    "shipping":  ["/pages/shipping", "/pages/shipping-policy", "/policies/shipping-policy", "/shipping"],
    "returns":   ["/pages/returns", "/pages/return-policy", "/policies/refund-policy", "/returns"],
    "refund":    ["/pages/refund-policy", "/policies/refund-policy", "/refund"],
    "privacy":   ["/pages/privacy-policy", "/policies/privacy-policy", "/privacy"],
    "terms":     ["/pages/terms", "/policies/terms-of-service", "/terms"],
    "about":     ["/pages/about-us", "/pages/about", "/about", "/about-us"],
    "contact":   ["/pages/contact", "/pages/contact-us", "/contact", "/contact-us"],
    "faq":       ["/pages/faq", "/pages/faqs", "/faq"],
}

def norm(s):
    # Unescape first β€” feeds serve entity-encoded titles ("Victoria’s Secret")
    # while the ingest decodes them; without this the audit reports false gaps.
    s = s or ""
    for _ in range(3):
        u = _html.unescape(s)
        if u == s: break
        s = u
    return re.sub(r"[^a-z0-9]+", " ", s.lower()).strip()

def detect(url):
    # Each call is snapshot-cached, so this is one fetch per feed (not per call).
    if fetch_all_products(url): return "shopify"
    if fetch_all_products_woo(url): return "woocommerce"
    if fetch_all_products_generic(url): return "generic"
    return "unknown"

def live_products(url, platform):
    if platform == "shopify":
        return [re.sub(r"\s+", " ", str(p.get("title") or "")).strip() for p in (fetch_all_products(url) or [])]
    if platform == "woocommerce":
        return [re.sub(r"\s+", " ", str(p.get("name") or "")).strip() for p in (fetch_all_products_woo(url) or [])]
    if platform == "generic":
        return [p["title"] for p in (fetch_all_products_generic(url) or [])]
    return []

def live_collections(url, platform):
    """collection title -> set(member product titles), live."""
    if platform != "shopify":
        return {}
    prods = {str(p.get("handle")): re.sub(r"\s+"," ",str(p.get("title") or "")).strip() for p in (fetch_all_products(url) or [])}
    out = {}
    for h, cols in fetch_collection_map(url).items():
        t = prods.get(h)
        if not t: continue
        for c in cols:
            out.setdefault(c, set()).add(t)
    return out

def page_exists(base, paths):
    for p in paths:
        try:
            r = requests.get(base.rstrip("/") + p, headers=UA, timeout=20, allow_redirects=True)
            if r.status_code == 200 and len(r.text) > 500:
                return base.rstrip("/") + p
        except Exception:
            pass
    return None

def audit(url):
    print(f"\n{'='*72}\n{url}\n{'='*72}")
    platform = detect(url)
    print(f"platform: {platform}")
    if platform == "unknown":
        print("  no structured catalog AND no sitemap/schema.org β€” would need pure HTML crawl.")
        return
    docs = build_catalog_docs(url)
    ing_titles = {norm(d.metadata.get("product_title")) for d in docs}
    ing_coll = set()
    for d in docs:
        for c in (d.metadata.get("collections") or "").split("|"):
            if c.strip(): ing_coll.add(norm(c))

    # 1) PRODUCTS
    lp = live_products(url, platform)
    lp_n = {norm(t) for t in lp if t}
    missing_p = [t for t in lp if t and norm(t) not in ing_titles]
    pc = (len(lp_n) - len({norm(t) for t in missing_p})) / max(len(lp_n), 1) * 100
    priced = sum(1 for d in docs if d.metadata.get("price") is not None)
    print(f"\nPRODUCTS : {len(lp_n)} live | {len(ing_titles)} ingested | coverage {pc:.1f}% | priced {priced}/{len(docs)}")
    if missing_p: print(f"   MISSING ({len(missing_p)}): {missing_p[:8]}")

    # 2) COLLECTIONS
    lc = live_collections(url, platform)
    if lc:
        empty = [c for c in lc if norm(c) not in ing_coll]
        cc = (len(lc) - len(empty)) / max(len(lc), 1) * 100
        print(f"\nCOLLECTIONS: {len(lc)} live | captured {len(lc)-len(empty)} | coverage {cc:.1f}%")
        if empty: print(f"   NOT CAPTURED ({len(empty)}): {empty[:8]}")
    else:
        print("\nCOLLECTIONS: none (not Shopify, or store has no collections)")

    # 3) KEY PAGES (prose β€” needs the CRAWL, not the catalog feed)
    print("\nKEY PAGES (exist on site β†’ must be reached by the crawler):")
    base = re.sub(r"(https?://[^/]+).*", r"\1", url)
    found, missing_pg = [], []
    for label, paths in KEY_PAGES.items():
        hit = page_exists(base, paths)
        (found if hit else missing_pg).append(label)
    print(f"   present: {found}")
    print(f"   not found at common paths: {missing_pg}")

def _idk(ans):
    a = (ans or "").lower()
    return (not a) or any(p in a for p in (
        "don't have", "do not have", "couldn't find", "could not find", "not sure",
        "i don't know", "no information", "don't have specific", "reach us at"))

def bot_probe(db_name, url):
    """Query the DEPLOYED bot to verify prose + variants actually ANSWER (not just
    exist on the site). GT pulled live, so probes are self-grounding.
      python evals/coverage_eval.py --bot babyfy https://babyfy.pk/"""
    import json, urllib.request
    BASE = "https://re-coder376--ai-chatbot-serve.modal.run"
    def _ask(key, q):
        body = json.dumps({"question": q, "stream": False}).encode()
        r = urllib.request.Request(BASE + "/chat", data=body, method="POST",
                                   headers={"X-Widget-Key": key, "Content-Type": "application/json"})
        return json.load(urllib.request.urlopen(r, timeout=120)).get("answer") or ""
    print(f"\n{'='*72}\nBOT PROBE: {db_name}  ({url})\n{'='*72}")
    d = json.load(urllib.request.urlopen(urllib.request.Request(
        BASE + "/admin/embed-code?ttl=1d",
        headers={"Authorization": f"Bearer {db_name}", "X-Admin-DB": db_name}), timeout=90))
    m = re.search(r"key=([^\"'&\s>]+)", d.get("snippet", ""))
    if not m:
        print("  could not mint widget key"); return
    key = m.group(1)
    probes = ["what is your shipping policy?", "do you accept returns or refunds?"]
    for p in (fetch_all_products(url) or []):
        vs = [v for v in (p.get("variants") or []) if str(v.get("title") or "").lower() not in ("", "default title")]
        if len(vs) > 1:
            title = re.sub(r"\s+", " ", str(p.get("title") or "")).strip()
            opt = (p.get("options") or [{}])[0].get("name", "options")
            probes.append(f"what {str(opt).lower()} does the {title} come in?")
            oos = next((v for v in vs if not v.get("available")), None)
            if oos:
                probes.append(f"is the {oos.get('title')} {title} in stock?")
            break
    p = f = 0
    for q in probes:
        ans = _ask(key, q)
        ok = not _idk(ans)
        p += ok; f += (not ok)
        print(f"  [{'ANSWERED' if ok else 'IDK/abstain'}] {q}\n      -> {ans[:150].strip()}")
    print(f"\n  {db_name}: {p}/{p+f} probes answered")

if __name__ == "__main__":
    if len(sys.argv) > 1 and sys.argv[1] == "--bot":
        bot_probe(sys.argv[2], sys.argv[3]); sys.exit(0)
    sites = sys.argv[1:] or DEFAULT_SITES
    for s in sites:
        try:
            audit(s)
        except Exception as e:
            import traceback; traceback.print_exc(); print(f"  ERROR {s}: {e}")
    print(f"\n{'#'*72}\nDONE. Coverage = % of the LIVE universe present in the ingest.\n"
          f"Gaps above are the exact things a customer could ask that the bot can't yet answer.\n{'#'*72}")