AI_Chatbot / crawl_gate.py
Hamza-Naimat's picture
gate: scale garbage-title tolerance with corpus size
64d3efb
Raw
History Blame Contribute Delete
27.1 kB
"""
crawl_gate.py — post-crawl verification gate. One line out: PASS or FAIL+why.
Layer 1 invariants over every ingested chunk (junk, titles, prices).
Layer 2 ground-truth diff: Shopify /products.json vs crawled chunks
(coverage + exact price accuracy, deterministic, no LLM).
Layer 3 auto-generated quiz with computed answers vs a running server
(optional, needs --quiz <base_url>).
Usage:
python crawl_gate.py <db_name> # layers 1+2
python crawl_gate.py <db_name> --sqlite <path> # explicit chroma.sqlite3
python crawl_gate.py <db_name> --quiz <base_url> # + layer 3
Exit 0 = PASS, 1 = FAIL. Full report: databases/<db>/gate_report.json
"""
import argparse
import json
import math
import os
import random
import re
import sqlite3
import sys
import time
import urllib.parse
from pathlib import Path
import requests
sys.path.insert(0, str(Path(__file__).parent))
from pipeline_check import JUNK_PATTERNS, TITLE_BAD # noqa: E402
UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/122.0 Safari/537.36"}
COVERAGE_MIN = 0.98 # GT products that must have a product chunk
PRICE_ACC_MIN = 0.99 # matched products whose price equals a live variant price
PRICE_META_WARN = 0.70 # product chunks carrying price metadata (warn only)
QUIZ_PASS_MIN = 0.90 # layer-3 answer quiz pass-rate floor (publish threshold).
# NOT 1.0: a single flaky free-LLM key would false-quarantine
# an otherwise-good DB on an unattended auto-publish.
def _reliable_quiz(label: str) -> bool:
"""A layer-3 quiz item whose GT is UNAMBIGUOUS enough to gate publishing on:
exact named-product price + clear absence. Aggregate/category items (rank-*,
cat-*, count:*, bounds:*) are advisory only — their GT disagrees with correct
bot behaviour (in-stock-extreme preference; title-substring vs tag membership)."""
return label.startswith("exact-price") or label.startswith("absence")
_HANDLE_RE = re.compile(r"/products/([^/?#]+)")
NAV_CHROME = re.compile(r"shop now|click to enlarge|sold out|pre[\s-]?order|add to (cart|wishlist)|view (cart|details)|quick view|click to (enlarge|zoom)", re.I)
_CAT_STOP = {
# structural / generic
"the","and","for","with","your","new","pack","size","gift","quality","piece","pieces",
"original","brand","set","sets","style","styles","design","designs","print","printed",
"premium","deluxe","classic","assorted","multi","regular","standard","custom","edition",
"single","double","value","combo","item","items","product","products","range",
# adjectives of dimension/shape/texture
"mini","large","small","big","medium","jumbo","round","square","soft","hard","smooth","flat",
# colours (incl. plurals — "colours" is what slipped through before) + common colour names
"color","colour","colors","colours","colored","coloured","multicolor","multicolour",
"black","blue","white","red","green","yellow","pink","purple","orange","brown","grey","gray",
"gold","golden","silver","clear","transparent","rainbow","pastel",
# materials
"plastic","metal","steel","wooden","wood","glass","paper","rubber","leather","fabric","cotton",
}
_ABSENCE_CANDIDATES = ["backpack","umbrella","mattress","bicycle","helmet","perfume","jacket","tyre","blanket","kettle"]
# ── chunk loading ────────────────────────────────────────────────────────────
def load_chunks(sqlite_path: str) -> list[dict]:
con = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True)
c = con.cursor()
c.execute("SELECT id, key, string_value, int_value, float_value FROM embedding_metadata")
by_id: dict = {}
for _id, key, sv, iv, fv in c.fetchall():
d = by_id.setdefault(_id, {})
d[key] = sv if sv is not None else (fv if fv is not None else iv)
con.close()
return [d for d in by_id.values() if d.get("chroma:document")]
# ── layer 1: invariants ──────────────────────────────────────────────────────
def layer1(chunks: list[dict]) -> dict:
junk: dict = {}
junk_samples: dict = {}
bad_titles, nonpos = [], []
unprintable = 0
for ch in chunks:
body = str(ch.get("chroma:document") or "")
head = body[:2000]
# isspace() (not just \n\r\t) so non-breaking/unicode spaces (\xa0 from
# Shopify specs "Type :\xa0Gel Pen") count as printable, not binary —
# NBSP is whitespace, not corruption. Guards against false unprintable.
printable = sum(1 for x in head if x.isprintable() or x.isspace())
if printable / max(1, len(head)) < 0.95:
unprintable += 1
for pat, label in JUNK_PATTERNS:
mm = pat.search(body)
if mm:
junk[label] = junk.get(label, 0) + 1
if len(junk_samples.get(label, [])) < 4:
s = max(0, mm.start() - 40)
junk_samples.setdefault(label, []).append({
"snip": body[s:mm.end() + 40].replace("\n", " "),
"kind": ch.get("chunk_kind"), "source": ch.get("source"),
"url": ch.get("url") or ch.get("source_url"),
"head": body[:120].replace("\n", " "),
})
break
t = str(ch.get("product_title") or "")
if t:
# <3 (not <4): real 3-char product names exist ("ABD" — toycompany).
if len(t.strip()) < 3:
bad_titles.append(("short title", t))
else:
_hit = False
for pat, label in TITLE_BAD:
if pat.search(t):
bad_titles.append((label, t[:60])); _hit = True
break
if not _hit and NAV_CHROME.search(t):
bad_titles.append(("nav-chrome", t[:60]))
pv = ch.get("price")
if pv is not None:
try:
if float(pv) <= 0:
nonpos.append((t[:40], pv, ch.get("chunk_kind"), str(ch.get("source"))[-60:], body[:90].replace("\n", " ")))
except (TypeError, ValueError):
nonpos.append((t[:40], pv, ch.get("chunk_kind"), str(ch.get("source"))[-60:], body[:90].replace("\n", " ")))
prod = [ch for ch in chunks if str(ch.get("chunk_kind") or "") == "product"]
priced = sum(1 for ch in prod if ch.get("price") is not None)
return {
"chunks": len(chunks), "product_chunks": len(prod),
"price_coverage": round(priced / len(prod), 3) if prod else None,
"junk": junk, "junk_samples": junk_samples,
"bad_titles": bad_titles[:20], "bad_title_count": len(bad_titles),
"nonpositive_prices": nonpos[:10], "nonpositive_count": len(nonpos),
"unprintable_chunks": unprintable,
}
# ── layer 2: Shopify ground-truth diff ───────────────────────────────────────
def fetch_shopify_gt(base_url: str) -> list[dict] | None:
"""All products from the store's own catalog API. None = not Shopify/blocked."""
base = base_url.rstrip("/")
products, page = [], 1
endpoint = f"{base}/products.json"
while page <= 40:
r = None
for attempt in range(3):
try:
r = requests.get(endpoint, params={"limit": 250, "page": page}, headers=UA, timeout=45)
break
except Exception:
if attempt == 2:
return None if page == 1 else products
time.sleep(5)
if r.status_code != 200:
if page == 1 and not endpoint.endswith("/collections/all/products.json"):
endpoint = f"{base}/collections/all/products.json"
continue
return None if page == 1 else products
try:
batch = r.json().get("products") or []
except Exception:
return None if page == 1 else products
if not batch:
break
for p in batch:
variants = p.get("variants") or []
prices = set()
for v in variants:
try:
prices.add(round(float(v.get("price")), 2))
except (TypeError, ValueError):
pass
products.append({
"handle": str(p.get("handle") or ""),
"title": str(p.get("title") or ""),
"prices": sorted(prices),
"available": any(v.get("available") for v in variants) if variants and "available" in variants[0] else None,
})
page += 1
time.sleep(0.4)
return products
def _norm_url(u: str) -> str:
"""Canonical product-URL key: unquote, drop scheme/query/fragment, lowercase,
strip trailing slash. Universal across platforms (Shopify /products/<h>, Woo
/product/<s>, generic flat URLs) so ingest and GT match on the same key."""
u = urllib.parse.unquote(str(u or "")).split("#")[0].split("?")[0].strip().lower()
u = re.sub(r"^https?://", "", u).rstrip("/")
return u
def _norm_title(t: str) -> str:
return re.sub(r"[^a-z0-9]+", " ", str(t or "").lower()).strip()
def layer2(chunks: list[dict], crawl_url: str) -> dict:
"""Ground-truth diff against the store's own structured catalog — Shopify,
WooCommerce, or generic JSON-LD/microdata (the SAME source catalog_api ingests
from). Platform-agnostic match: by canonical product URL first, then normalized
title, so non-Shopify stores are verified for coverage + price, not skipped."""
from services.catalog_api import catalog_ground_truth
gt = catalog_ground_truth(crawl_url)
if not gt:
return {"skipped": "no structured catalog (Shopify/Woo/JSON-LD) — GT diff unavailable for this site"}
by_url: dict = {}
by_title: dict = {}
for ch in chunks:
if str(ch.get("chunk_kind") or "") != "product":
continue
u = _norm_url(ch.get("source"))
if u:
by_url.setdefault(u, []).append(ch)
t = _norm_title(ch.get("canonical_product_title") or ch.get("product_title"))
if t:
by_title.setdefault(t, []).append(ch)
used = set()
missing, mismatch, matched, price_ok = [], [], 0, 0
for p in gt:
label = p.get("title") or p.get("source") or ""
chs = by_url.get(_norm_url(p.get("source")))
if not chs:
chs = by_title.get(_norm_title(p.get("title")))
chs = [c for c in (chs or []) if id(c) not in used] or None
if not chs:
missing.append(label)
continue
used.add(id(chs[0]))
matched += 1
pvs = [float(ch["price"]) for ch in chs if ch.get("price") is not None]
if not p["prices"] or not pvs:
price_ok += 1 # nothing to compare (unpriced GT or OOS-suppressed)
elif any(abs(pv - g) <= 0.011 for pv in pvs for g in p["prices"]):
price_ok += 1
else:
mismatch.append({"title": label[:60], "crawled": pvs[:3], "live": p["prices"][:5]})
extra = sum(len(v) for v in by_title.values()) - matched
return {
"gt_products": len(gt), "matched": matched,
"coverage": round(matched / len(gt), 4) if gt else None,
"price_accuracy": round(price_ok / matched, 4) if matched else None,
"missing_count": len(missing), "missing": missing[:25],
"price_mismatch_count": len(mismatch), "price_mismatch": mismatch[:25],
"extra_handles_count": max(0, extra), "extra_handles": [],
"_gt": gt, # consumed by layer 3, stripped before saving
}
# ── layer 3: auto-quiz against a running server ──────────────────────────────
def _num_in(ans: str, val: float) -> bool:
flat = ans.replace(",", "")
pat = rf"\b{int(val)}(?:\.\d{{1,2}})?\b" if float(val).is_integer() else rf"\b{val:g}\b"
return re.search(pat, flat) is not None
def _widget_key(base: str, db: str, password: str) -> str:
r = requests.get(f"{base}/admin/embed-code", params={"password": password, "db_name": db}, timeout=60)
r.raise_for_status()
try:
j = r.json()
for k in ("widget_key", "key", "token"):
if isinstance(j, dict) and j.get(k):
return j[k]
except Exception:
pass
m = re.search(r"wk1\.[A-Za-z0-9._\-]+", r.text)
if not m:
raise RuntimeError("widget key not found in /admin/embed-code response")
return m.group(0)
_TRANSIENT = re.compile(r"(?i)unable to respond|try again in a moment|please try again|"
r"rate limit|temporarily unavailable|service unavailable")
def _ask(base: str, wk: str, q: str, tries: int = 4) -> str:
"""Retry 429/5xx + transient-error BODIES so the quiz score reflects answer
QUALITY, not a cold/rate-limited free-LLM key. A real customer would resend."""
last = ""
for a in range(tries):
try:
r = requests.post(f"{base}/chat", json={"question": q, "stream": False},
headers={"X-Widget-Key": wk}, timeout=180)
if r.status_code == 429 or r.status_code >= 500:
time.sleep(15 + a * 10)
continue
try:
ans = str(r.json().get("answer") or r.text)
except Exception:
ans = r.text
last = ans
if _TRANSIENT.search(ans) and a < tries - 1:
time.sleep(8 + a * 6)
continue
return ans
except requests.exceptions.RequestException:
time.sleep(8 + a * 6)
return last
def gen_quiz(gt: list[dict] | None, chunks: list[dict], exclude_terms: tuple = ()) -> list[dict]:
"""Questions with answers computed from ground truth (GT preferred, else chunk meta).
exclude_terms (per-DB count_exclude_terms): accessory/refill words dropped from
category groups so the count GT matches the engine's count_exclude logic."""
if gt:
items = [{"title": p["title"], "price": p["prices"][0], "cats": str(p.get("categories") or "").lower()}
for p in gt if p["prices"] and p["prices"][0] > 0]
else:
seen, items = set(), []
for ch in chunks:
t = str(ch.get("canonical_product_title") or ch.get("product_title") or "")
if str(ch.get("chunk_kind") or "") == "product" and t and ch.get("price") and t not in seen:
seen.add(t)
items.append({"title": t, "price": float(ch["price"]), "cats": str(ch.get("categories") or "").lower()})
if len(items) < 3:
return []
rng = random.Random(42) # deterministic across reruns
qs = []
for it in rng.sample(items, min(3, len(items))):
qs.append({"q": f"What is the price of {it['title']}?", "expect_num": it["price"], "label": "exact-price"})
cheapest = min(items, key=lambda x: x["price"])
dearest = max(items, key=lambda x: x["price"])
qs.append({"q": "What is the absolute cheapest product you sell?", "expect_num": cheapest["price"], "label": "rank-min"})
qs.append({"q": "What is the most expensive product in your entire store?", "expect_num": dearest["price"], "label": "rank-max"})
prices = sorted(x["price"] for x in items)
cap = prices[min(4, len(prices) - 1)] # ~5 items qualify
under = [x for x in items if x["price"] <= cap]
qs.append({"q": f"Which products do you have under {cap + 1:.0f}?", "label": "bounds",
"expect_any_num": [x["price"] for x in under]})
# per-category: counts, scoped extremes, full-set bounds (catches pollution/recall/count bugs).
# Category membership MUST mirror the engine (catalog_query._execute_spec count
# branch): when ≥50% of products are tagged, the engine counts by the STRUCTURED
# categories field (singular/plural word match), NOT the title — so a title-word
# GT systematically disagreed and false-failed. Match that exactly here.
from collections import Counter
tagged = sum(1 for it in items if it.get("cats", "").strip())
is_tagged = bool(items) and (tagged / len(items)) >= 0.5
def _cat_variants(a: str) -> set:
vs = {a}
if a.endswith("s") and len(a) > 3:
vs.add(a[:-1])
elif len(a) > 3:
vs.add(a + "s")
return vs
def _field(it: dict) -> str:
# tagged catalog → categories field only (mirrors _cats_hit); else title+cats (_hit hay).
return it.get("cats", "") if is_tagged else (it["title"].lower() + " " + it.get("cats", ""))
def _cat_match(it: dict, cat: str) -> bool:
hay = _field(it)
return any(re.search(rf"\b{re.escape(v)}\b", hay) for v in _cat_variants(cat))
cnt = Counter()
for it in items:
toks = re.findall(r"[A-Za-z]{4,}", _field(it))
for w in set(toks):
if w not in _CAT_STOP:
cnt[w] += 1
# mid-size buckets only: skip giant buckets (367 notebooks) + brand/adjective noise; lands on clean categories
_excl_re = re.compile(r"\b(?:" + "|".join(re.escape(str(t)) for t in exclude_terms) + r")\b", re.I) if exclude_terms else None
for cat in [c for c, n in cnt.most_common() if 3 <= n <= 40][:4]:
grp = [x for x in items if _cat_match(x, cat)
and not (_excl_re and cat not in str(_excl_re.pattern).lower() and _excl_re.search(x["title"]))]
if not 3 <= len(grp) <= 40:
continue
gp = sorted(g["price"] for g in grp)
qs.append({"q": f"How many {cat} products do you have?", "expect_count": len(grp), "label": f"count:{cat}"})
qs.append({"q": f"What is the cheapest {cat} you sell?", "expect_num": gp[0], "label": f"cat-min:{cat}"})
qs.append({"q": f"What is the most expensive {cat} you sell?", "expect_num": gp[-1], "label": f"cat-max:{cat}"})
hi = gp[min(len(gp) - 1, max(1, len(gp) // 2))]
inb = [p for p in gp if gp[0] <= p <= hi]
if 2 <= len(inb) <= 12:
qs.append({"q": f"Which {cat} products are priced between {gp[0]:.0f} and {hi:.0f}?",
"expect_all_num": inb, "label": f"bounds:{cat}"})
present = " ".join(x["title"].lower() for x in items)
for cand in _ABSENCE_CANDIDATES:
if cand not in present:
qs.append({"q": f"Do you sell {cand}s? If you don't carry them, say so.",
"expect_absent": True, "label": f"absence:{cand}"})
break
return qs
def layer3(base: str, db: str, password: str, gt, chunks, exclude_terms: tuple = ()) -> dict:
quiz = gen_quiz(gt, chunks, exclude_terms)
if not quiz:
return {"skipped": "not enough priced products to generate a quiz"}
wk = _widget_key(base, db, password)
results, passed = [], 0
for item in quiz:
ans = _ask(base, wk, item["q"])
if "expect_num" in item:
ok = _num_in(ans, item["expect_num"])
elif "expect_count" in item:
ok = _num_in(ans, item["expect_count"])
elif "expect_all_num" in item:
ok = all(_num_in(ans, v) for v in item["expect_all_num"]) # full membership, not any
elif "expect_absent" in item:
al = ans.lower()
ok = bool(re.search(r"don'?t (carry|stock|sell|have)|do not (carry|stock|sell|have)|"
r"not (carry|stock|available)|we don'?t|currently (don'?t|do not)|no .{0,25}(available|in stock|found)", al))
else:
ok = any(_num_in(ans, v) for v in item["expect_any_num"])
passed += ok
results.append({"label": item["label"], "q": item["q"], "pass": bool(ok),
"expected": item.get("expect_num") or item.get("expect_count") or item.get("expect_all_num")
or item.get("expect_any_num") or ("absent" if item.get("expect_absent") else None), "answer": ans[:300]})
time.sleep(4)
return {"asked": len(quiz), "passed": passed, "results": results}
# ── verdict + entry points ───────────────────────────────────────────────────
def run_gate(db_name: str, sqlite_path: str, crawl_url: str = "",
quiz_base: str = "", password: str = "", exclude_terms: tuple = ()) -> dict:
chunks = load_chunks(sqlite_path)
report = {"db": db_name, "sqlite": sqlite_path, "layer1": layer1(chunks)}
gt = None
if crawl_url:
report["layer2"] = layer2(chunks, crawl_url)
gt = report["layer2"].pop("_gt", None)
else:
report["layer2"] = {"skipped": "no crawl_url"}
if quiz_base:
try:
report["layer3"] = layer3(quiz_base.rstrip("/"), db_name, password, gt, chunks, exclude_terms)
except Exception as e:
report["layer3"] = {"error": str(e)}
fails = []
l1 = report["layer1"]
if l1["chunks"] == 0:
fails.append("0 chunks in DB")
if l1["junk"]:
fails.append(f"junk in chunks: {l1['junk']}")
# Tolerance scales with corpus: a broken extractor yields dozens of bad titles,
# while 1-2 in thousands are store-authored quirks (allurebeauty's own
# "…Face Wash 100G..." title). Kept in the report either way.
_bad_tol = max(2, l1["chunks"] // 1000)
if l1["bad_title_count"] > _bad_tol:
fails.append(f"{l1['bad_title_count']} garbage titles (e.g. {l1['bad_titles'][:2]})")
if l1["nonpositive_count"]:
fails.append(f"{l1['nonpositive_count']} non-positive prices")
if l1["unprintable_chunks"]:
fails.append(f"{l1['unprintable_chunks']} binary/unprintable chunks")
l2 = report["layer2"]
if not l2.get("skipped"):
if l2["coverage"] is not None and l2["coverage"] < COVERAGE_MIN:
fails.append(f"catalog coverage {l2['coverage']:.1%} < {COVERAGE_MIN:.0%} ({l2['missing_count']} live products missing)")
if l2["price_accuracy"] is not None and l2["price_accuracy"] < PRICE_ACC_MIN:
fails.append(f"price accuracy {l2['price_accuracy']:.1%} < {PRICE_ACC_MIN:.0%} ({l2['price_mismatch_count']} wrong prices)")
l3 = report.get("layer3") or {}
if l3.get("asked"):
# The publish verdict enforces ONLY on quiz items whose GT is UNAMBIGUOUS:
# exact-price (named product → its exact price) and absence (clearly-absent
# product → "we don't carry"). The aggregate/category items (rank-min/max,
# cat-min/max, count:*, bounds:*) are kept in the report as advisory but NOT
# gated: their GT systematically disagrees with correct bot behaviour —
# rank ignores the bot's in-stock-extreme preference, and "what counts as an
# eraser" (title-substring vs structured tag) is genuinely ambiguous. Gating
# on them would quarantine good DBs. (See _RELIABLE_QUIZ.)
rel = [r for r in (l3.get("results") or []) if _reliable_quiz(r.get("label", ""))]
rel_pass = sum(1 for r in rel if r.get("pass"))
l3["reliable_asked"], l3["reliable_passed"] = len(rel), rel_pass
if rel:
need = math.ceil(len(rel) * QUIZ_PASS_MIN)
if rel_pass < need:
fails.append(f"reliable quiz {rel_pass}/{len(rel)} < {need} ({QUIZ_PASS_MIN:.0%}) — "
f"failed: {[r['label'] for r in rel if not r['pass']]}")
warns = []
_l3adv = (l3.get("asked") or 0) - (l3.get("reliable_asked") or 0)
if l3.get("asked") and l3["passed"] < l3["asked"]:
warns.append(f"advisory quiz {l3['passed']}/{l3['asked']} (incl. {_l3adv} GT-ambiguous "
f"aggregate items not gated): {[r['label'] for r in (l3.get('results') or []) if not r['pass']]}")
if l1["price_coverage"] is not None and l1["price_coverage"] < PRICE_META_WARN:
warns.append(f"only {l1['price_coverage']:.0%} of product chunks carry price metadata")
report["warnings"] = warns
report["failures"] = fails
report["verdict"] = "PASS" if not fails else "FAIL"
return report
def run_gate_for_db(db_name: str, db_dir: str, crawl_url: str = "", quiz_base: str = "", password: str = "") -> dict:
"""Importable entry point (app.py post-crawl). Writes gate_report.json next to the DB."""
sq = str(Path(db_dir) / "chroma.sqlite3")
_excl = ()
try:
_cfg = json.loads((Path(db_dir) / "config.json").read_text(encoding="utf-8-sig"))
_excl = tuple(_cfg.get("count_exclude_terms") or ())
except Exception:
pass
report = run_gate(db_name, sq, crawl_url, quiz_base, password, _excl)
try:
out = Path(db_dir) / "gate_report.json"
out.write_text(json.dumps(report, ensure_ascii=False, indent=1, default=str), encoding="utf-8")
except Exception:
pass
return report
def main():
ap = argparse.ArgumentParser()
ap.add_argument("db_name")
ap.add_argument("--sqlite", default="")
ap.add_argument("--url", default="", help="override crawl_url for GT diff")
ap.add_argument("--quiz", default="", help="server base URL → run auto-quiz")
ap.add_argument("--password", default=os.environ.get("ADMIN_PASSWORD", ""))
a = ap.parse_args()
db_dir = Path("databases") / a.db_name
sq = a.sqlite or str(db_dir / "chroma.sqlite3")
crawl_url = a.url
_cfg = {}
cfg_p = db_dir / "config.json"
if cfg_p.exists():
try:
_cfg = json.loads(cfg_p.read_text(encoding="utf-8-sig"))
except Exception:
pass
if not crawl_url:
crawl_url = _cfg.get("crawl_url") or ""
exclude_terms = tuple(_cfg.get("count_exclude_terms") or ())
report = run_gate(a.db_name, sq, crawl_url, a.quiz, a.password, exclude_terms)
out = Path(sq).parent / "gate_report.json"
out.write_text(json.dumps(report, ensure_ascii=False, indent=1, default=str), encoding="utf-8")
l1, l2 = report["layer1"], report["layer2"]
print(f"chunks={l1['chunks']} product={l1['product_chunks']} priced={l1['price_coverage']}")
if not l2.get("skipped"):
pa = l2['price_accuracy']
print(f"GT: {l2['matched']}/{l2['gt_products']} matched, coverage={(l2['coverage'] or 0):.1%}, price_acc={'n/a' if pa is None else format(pa, '.1%')}")
else:
print(f"GT: {l2['skipped']}")
if report.get("layer3", {}).get("asked"):
print(f"quiz: {report['layer3']['passed']}/{report['layer3']['asked']}")
for w in report["warnings"]:
print(f"WARN: {w}")
for f in report["failures"]:
print(f"FAIL: {f}")
print(f"\n{report['verdict']} (full report: {out})")
sys.exit(0 if report["verdict"] == "PASS" else 1)
if __name__ == "__main__":
main()